diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index 6e3ec1a..ff25e97 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -94,31 +94,43 @@ contours are row 2's first two groups. ### 1.2 The measured layout +> **Updated for Ω-W1-T4's filter-mod move and toggle conversion.** The table below was +> re-measured against the shipped `deck_groups.cpp`/`knob_deck.cpp` after two changes this +> track made: the filter's mod-depth cell moved from FILTER to FILTER ENV (one cell each +> way), and the six toggles that used to be two-segment (`Off\|On` on PITCH ENV/FILTER/MASTER, +> `Staged\|Spline` on PITCH ENV/FILTER ENV/AMP ENVELOPE) are now the single-button +> `kEnable`/`kMode` styles — see `knob_deck.h`'s `DeckToggleStyle`. The per-deck corner +> radio these three envelope decks carried is gone (superseded by the click-to-focus +> overlay border, `core/instrument/CLAUDE.md`'s envelope-overlay section); MASTER alone +> keeps the corner slot, for its passive gain-reduction lamp. + `deckGroupWidth(g) = max(captionRowWidth, knobRowWidth) + 2·kDeckGroupPadX`, with -`captionRowWidth = captionWidth + Σ(kDeckToggleGap + 2·segWidth) + (radio ? 4 + 12 : 0)` -and `knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle)`. Metrics: -`kDeckCellW 60`, `kDeckCellH 74`, `kDeckKnobSize 40`, `kDeckCellLabelH 16`, -`kDeckCaptionH 20`, `kDeckToggleH 18`, `kDeckGroupPadX 6`, `kDeckGroupPadY 4`, -`kDeckCaptionGap 2`, `kDeckToggleGap 4`, `kDeckGroupGap 12`, `kDeckRowGap 8`, -`kDeckRadioSize 12`, `kDeckGroupH 104`. +`captionRowWidth = captionWidth + Σ(kDeckToggleGap + toggleWidth) + (radio ? kDeckToggleGap + +kDeckRadioSize : 0)` and `knobRowWidth = |cellIds|·kDeckCellW + (rowToggle ? kDeckToggleGap + +rowToggleWidth : 0)`. `toggleWidth` is the WHOLE control either way — a `kSegmented` toggle +just halves it into two segments at that width; a single-button `kEnable`/`kMode` toggle +draws the whole span as one button. Metrics: `kDeckCellW 60`, `kDeckCellH 74`, `kDeckKnobSize +40`, `kDeckCellLabelH 16`, `kDeckCaptionH 20`, `kDeckToggleH 18`, `kDeckGroupPadX 6`, +`kDeckGroupPadY 4`, `kDeckCaptionGap 2`, `kDeckToggleGap 4`, `kDeckGroupGap 12`, `kDeckRowGap +8`, `kDeckRadioSize 12`, `kDeckGroupH 104`, `kEnableBtnW 52`, `kEnvModeW 46`. | Group | Row | Caption run | Knob run | **Width** | Δ | Control inventory | |---|---|---|---|---|---|---| -| **PITCH/RATE** | 1 | 70 + 4 + 2·48 = 170 | 3 × 60 = **180** | **192** | +42 | 3 cells `Key Trk` / `Rate` / `Pitch`; caption toggle `Varisp\|Presrv` (48) | -| **FILTER** | 1 | 46 + 4 + 2·32 + 4 + 2·44 = 206 | 7 × 60 = **420** | **432** | −92 | 7 cells (morph, cutoff, Q, drive, mod amt, vel, key trk); caption toggle `Off\|On` (32); **caption toggle 2 `Band\|Notch` (44) — moved from the knob row** | +| **PITCH/RATE** | 1 | 70 + 4 + 2·48 = 170 | 3 × 60 = **180** | **192** | +42 | 3 cells `Key Trk` / `Rate` / `Pitch`; caption toggle `Varisp\|Presrv` (96, segmented) | +| **FILTER** | 1 | 46 + 4 + 52 + 4 + 2·44 = 194 | 6 × 60 = **360** | **372** | −60 vs Γ | 6 cells (morph, cutoff, Q, drive, vel, key trk) — **mod amt left for FILTER ENV**; caption toggle `Filter` (52, single-button ENABLE — was `Off\|On`); caption toggle 2 `Band\|Notch` (88, segmented) | | **VELOCITY** | 1 | 54 | 3 × 60 = **180** | **192** | 0 | 3 curve-popup cells (amp, pitch, filter) | -| **VOICE** | 1 | 38 + 4 + 2·40 = 122 | 60 + 4 + 2·44 = **152** | **164** | 0 | 1 cell (voice count); caption toggle `Poly\|Mono` (40); **row toggle `Retrig\|Legato` (44) stays** — see note | -| **PITCH ENV** | 2 | 58 + 4 + 64 + 4 + 46 + 4 + 12 = 192 | 4 × 60 = **240** | **252** | 0 | 4 cells (A, H, D, Depth); caption toggle `Off\|On`; caption toggle 2 `Staged\|Spline`; corner radio | -| **FILTER ENV** | 2 | 66 + 4 + 46 + 4 + 12 = 132 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: A,H,D + 2 reserves); caption toggle 2; corner radio | -| **AMP ENVELOPE** | 2 | 78 + 4 + 88 + 4 + 46 + 4 + 12 = 236 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle `Gate\|Trig` (44); caption toggle 2; corner radio | -| **MASTER** | **1+2** | 46 + 4 + 2·32 + 4 + 12 = **130** | 60 + 8 + 62 = **130** | **142** | +70 | 1 cell (gain, upper-left); 1 **reserved** lower-left slot; caption toggle `Limiter Off\|On` (32); corner **bubble** (12, passive); **meter column 62 px, full double height** | +| **VOICE** | 1 | 38 + 4 + 2·40 = 122 | 60 + 4 + 2·44 = **152** | **164** | 0 | 1 cell (voice count); caption toggle `Poly\|Mono` (80, segmented); **row toggle `Retrig\|Legato` (88, segmented) stays** — see note | +| **PITCH ENV** | 2 | 58 + 4 + 52 + 4 + 46 = 164 | 4 × 60 = **240** | **252** | 0 | 4 cells (A, H, D, Depth); caption toggle `Envelope` (52, single-button ENABLE — was `Off\|On`); caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE — was `Staged\|Spline`); **no corner radio** | +| **FILTER ENV** | 2 | 66 + 4 + 46 = 116 | 6 × 60 = **360** | **372** | +60 vs Γ | 6 slots (Gate: A,H,D,S,R,**Mod** / Trigger: A,H,D + 2 reserves,**Mod**) — **mod amt arrives from FILTER**; caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE); **no corner radio**; no enable toggle of its own (FILTER's governs both) | +| **AMP ENVELOPE** | 2 | 78 + 4 + 88 + 4 + 46 = 220 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle `Gate\|Trig` (88, segmented); caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE — was `Staged\|Spline`); **no corner radio** | +| **MASTER** | **1+2** | 46 + 4 + 52 + 4 + 12 = **118** | 60 + 8 + 62 = **130** | **142** | +70 | 1 cell (gain, upper-left); 1 **reserved** lower-left slot; caption toggle `Limiter` (52, single-button ENABLE — was `Off\|On`); corner **bubble** (12, passive); **meter column 62 px, full double height** | **Row totals.** | | Natural content | Gutters at floor | **Row width** | |---|---|---|---| -| Row 1 | 192 + 432 + 192 + 164 = **980** | 16 + 16 + 16 = 48 | **1028** | -| Row 2 | 252 + 312 + 312 = **876** | 76 + 76 = 152 | **1028** | +| Row 1 | 192 + 372 + 192 + 164 = **920** | 36 + 36 + 36 = 108 | **1028** | +| Row 2 | 252 + 372 + 312 = **936** | 46 + 46 = 92 | **1028** | **Window floor.** diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 7f6af6b..a0ef4e6 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -218,9 +218,14 @@ automatable parameters."* It rejects the precedent, not one instance of it. ### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended) The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a -transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at -most one is overlay-active, and **none is a valid resting state — the editor opens there.** -Never persisted; it selects what is drawn, not what is played. +transient editor choice: **clicking anywhere in an envelope deck (amp, pitch, filter) — panel, +knob or button — focuses that deck's overlay**, and the focused deck takes an `AccentPrimary` +border. At most one is overlay-active, and **none is a valid resting state — the editor opens +there**, reached by clicking a control surface outside the three decks. Focus SETS rather than +toggles, so editing the deck you selected cannot deselect it; the overlay itself neither steals +nor clears focus for the same reason. Never persisted; it selects what is drawn, not what is +played. *(The per-deck corner radio this replaced is gone; MASTER keeps the corner slot for its +passive gain-reduction lamp.)* **The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a @@ -344,7 +349,7 @@ anything for a trigger shape. - `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required. - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know. - `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, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n−1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. 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, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **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. +- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n−1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. 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, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, and the cells present are CENTRED in it at their natural `kDeckCellW`**: a `-1` id reserves one cell's width without a cell, and the reserve is paid for in symmetric end margins, never in wider cells. 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 stretching the knobs off their pitch; only the reserve COUNT matters, so where a `-1` sits in `cellIds` is free. Two rejected laws, recorded so they are not re-proposed: dropping the reserves (reflows every neighbour on a mode flip, which is what the reserves exist to prevent), and dividing the run between the cells present (the retired law — it handed a reduced face 90–100 px cells). A SPANNING group is the one place a reserve's position still matters, because there it advances a fixed slot. - `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups` says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` / @@ -363,7 +368,7 @@ anything for a trigger shape. parameter set does not carry (key-track, voice count, master gain, preview velocity) and the labels for them. - `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. `kMeterColumnW` is the SUM of those three, exported so `deck_groups`' MASTER descriptor reserves exactly what the interior consumes — the column is banked to grow, and a reserve that did not track it would underfill or overrun silently. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveform_view`'s `resolveLaneSplit` answers** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Also owns `meterTickNumeralled` (the spec-pinned 0/−12/−24/−36/−48/−60 numeral set, beside the tick step it derives from), `meterNumeralRect` (bottom-clamped, so the floor tick's numeral cannot hang out of the gutter), and `meterSingleLaneState` — the one bar folds both channels PER FIELD, never picking a whole channel by level. Composes `engine/meter_ballistics` per channel and gives the gain-reduction lamp the peak tick's own hold-then-release, without which a catch smaller than 20 dB × the UI period is dark again before it has been drawn twice; the audio thread's clip flag is ORed in because it is the only latch that sees every block. `meterDrawEqual` is what lets the UI tick repaint on change alone. Also owns the editor's two paint-dispatch predicates, so neither lives as a bare comment in the shell: `meterFastPathEligible` (is a WM_PAINT dirty rect wholly inside the field?) and `meterBarsWithinField` (the self-containment invariant that fast path rests on), both asserted in `test_master_meter.cpp`. -- `deck_groups` — also home to `deckParamCommit` 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; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the −92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164. +- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `overlayEnvForGroup`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-FOCUS state machine (which deck names which envelope, 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; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the −92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164. - `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 points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above. - `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. - `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. diff --git a/src/core/instrument/param/param_id.h b/src/core/instrument/param/param_id.h index dfb3839..f933736 100644 --- a/src/core/instrument/param/param_id.h +++ b/src/core/instrument/param/param_id.h @@ -37,6 +37,10 @@ using ParamId = std::uint32_t; // control added later numerically beside its siblings instead of at the end of the table. // 1500-1599 (VELOCITY) and 1600-1699 (VOICE) are RESERVED and empty — a control either group // ever gains lands in its own range rather than in whatever range happened to be free. +// A block names the signal-flow STAGE, not the deck group a control happens to be drawn in, and +// the two have already diverged once: kParamFilterModAmount (1240) stays in the FILTER block +// while its knob moved to the FILTER ENVELOPE deck. Ids key on the control, so a redraw never +// renumbers — the freeze is what makes annotating this the fix rather than moving the row. enum : ParamId { kParamKeyTrackPitch = 1000, kParamRate = 1010, diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 4f51259..2a85d22 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -12,11 +12,23 @@ namespace { int id(DeckParam p) { return static_cast(p); } double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); } -// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays -// no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob -// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen -// the CONTOUR row, which has 152px of slack, not the SOUND row. -constexpr int kEnvModeSegW = 23; +// The three Staged|Spline mode selectors, as ONE button reading "Stage" or "Spline". Sized so +// each env group's caption row stays no wider than its knob row; the binding group is PITCH +// ENV, which reaches its four-cell knob row at 122 (AMP, the next tightest, at 126). +constexpr int kEnvModeW = 46; + +// The single-button enables ("Envelope" / "Filter" / "Limiter"). One width because they are one +// control grammar. MASTER is the group that BINDS it: its knob row measures 130 (the caption +// row, at this width, measures 118), so anything past 64 widens kDeckSpanningW and spends the +// editor's width budget. +constexpr int kEnableBtnW = 52; + +DeckToggleDesc enableButton(DeckParam p) { + return {id(p), kEnableBtnW, DeckToggleStyle::kEnable}; +} +DeckToggleDesc envModeButton(DeckParam p) { + return {id(p), kEnvModeW, DeckToggleStyle::kMode}; +} } // namespace double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; } @@ -27,14 +39,14 @@ std::vector sampleDeckGroups(PlayMode playMode) { std::vector out; { // PITCH/RATE. The three cells make the knob row 180, which is what the group measures - // from; the caption row (caption + gap + two 48px segments) must stay under it, so the + // from; the caption row (caption + gap + the 96px toggle) must stay under it, so the // caption reserve has a hard ceiling of 80 — past that the caption row overtakes the knob // row and the group grows past 192. Widening the group is not the answer if the text ever - // outgrows 80: narrow the Varisp|Presrv segments to 44 instead. + // outgrows 80: narrow the Varisp|Presrv toggle to 88 instead. DeckGroupDesc pitch; pitch.id = kGroupPitch; pitch.captionWidth = 70; - pitch.captionToggle = {id(DeckParam::kPitchEngine), 48}; + pitch.captionToggle = {id(DeckParam::kPitchEngine), 96}; pitch.cellIds = {id(DeckParam::kKeyTrack), id(DeckParam::kRate), id(DeckParam::kPitch)}; out.push_back(std::move(pitch)); } @@ -42,11 +54,10 @@ std::vector sampleDeckGroups(PlayMode playMode) { DeckGroupDesc penv; penv.id = kGroupPitchEnv; penv.captionWidth = 58; - penv.captionRadio = {id(DeckParam::kPitchEnvSelect)}; - penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32}; + penv.captionToggle = enableButton(DeckParam::kPitchEnvEnable); // The mode toggle rides the caption slack rather than the knob row — costs no group // width; see this module's CLAUDE.md bullet (knob_deck) for the headroom this relies on. - penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW}; + penv.captionToggle2 = envModeButton(DeckParam::kPitchEnvMode); penv.cellIds = {id(DeckParam::kPitchEnvAttack), id(DeckParam::kPitchEnvHold), id(DeckParam::kPitchEnvDecay), @@ -54,38 +65,41 @@ std::vector sampleDeckGroups(PlayMode playMode) { out.push_back(std::move(penv)); } { - // Tone shaping left-to-right, then the three modulation depths that all target cutoff. + // Tone shaping left-to-right, then the two modulation depths that stay with the tone + // stage. The envelope's own depth sits with the envelope — see FILTER ENV below. DeckGroupDesc filter; filter.id = kGroupFilter; filter.captionWidth = 46; - filter.captionToggle = {id(DeckParam::kFilterEnable), 32}; + filter.captionToggle = enableButton(DeckParam::kFilterEnable); filter.cellIds = {id(DeckParam::kFilterMorph), id(DeckParam::kFilterCutoff), id(DeckParam::kFilterQ), id(DeckParam::kFilterDrive), - id(DeckParam::kFilterModAmt), id(DeckParam::kFilterVel), id(DeckParam::kFilterKeyTrack)}; // The morph law rides the caption slack. Moving it back to the knob row costs the // group 92px and the SOUND row stops fitting its block. - filter.captionToggle2 = {id(DeckParam::kFilterLaw), 44}; + filter.captionToggle2 = {id(DeckParam::kFilterLaw), 88}; out.push_back(std::move(filter)); } { DeckGroupDesc fenv; fenv.id = kGroupFilterEnv; fenv.captionWidth = 66; - fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)}; - fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW}; + fenv.captionToggle2 = envModeButton(DeckParam::kFilterEnvMode); + // The mod DEPTH sits with the envelope it scales, last in the run — the kPitchEnvDepth + // precedent above. Why moving it renumbers nothing is param_id.h's, at the id table. if (trigger) { fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold), - id(DeckParam::kFilterTrigDecay), -1, -1}; + id(DeckParam::kFilterTrigDecay), -1, -1, + id(DeckParam::kFilterModAmt)}; } else { fenv.cellIds = {id(DeckParam::kFilterEnvAttack), id(DeckParam::kFilterEnvHold), id(DeckParam::kFilterEnvDecay), id(DeckParam::kFilterEnvSustain), - id(DeckParam::kFilterEnvRelease)}; + id(DeckParam::kFilterEnvRelease), + id(DeckParam::kFilterModAmt)}; } out.push_back(std::move(fenv)); } @@ -93,9 +107,8 @@ std::vector sampleDeckGroups(PlayMode playMode) { DeckGroupDesc amp; amp.id = kGroupAmpEnv; amp.captionWidth = 78; - amp.captionRadio = {id(DeckParam::kAmpEnvSelect)}; - amp.captionToggle = {id(DeckParam::kPlayMode), 44}; - amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW}; + amp.captionToggle = {id(DeckParam::kPlayMode), 88}; + amp.captionToggle2 = envModeButton(DeckParam::kAmpEnvMode); 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 reserve (-1) keeps the group's width — @@ -125,9 +138,9 @@ std::vector sampleDeckGroups(PlayMode playMode) { DeckGroupDesc voice; voice.id = kGroupVoice; voice.captionWidth = 38; - voice.captionToggle = {id(DeckParam::kVoiceMode), 40}; + voice.captionToggle = {id(DeckParam::kVoiceMode), 80}; voice.cellIds = {id(DeckParam::kVoiceCount)}; - voice.rowToggle = {id(DeckParam::kMonoTrigger), 44}; + voice.rowToggle = {id(DeckParam::kMonoTrigger), 88}; out.push_back(std::move(voice)); } { @@ -138,7 +151,7 @@ std::vector sampleDeckGroups(PlayMode playMode) { master.id = kGroupMaster; master.captionWidth = 46; master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true}; - master.captionToggle = {id(DeckParam::kLimiterEnable), 32}; + master.captionToggle = enableButton(DeckParam::kLimiterEnable); master.cellIds = {id(DeckParam::kMasterGain), -1}; // The reserve IS what the interior consumes — read from master_meter rather than // restated, so the two cannot drift when the column grows into MASTER's banked room. @@ -286,27 +299,12 @@ LiveCommit deckParamCommit(DeckParam id) { return LiveCommit::Reload; // unreachable for a valid enumerator; silences a warning. } -OverlayEnv overlayEnvForRadio(int radioId) { - switch (static_cast(radioId)) { - case DeckParam::kAmpEnvSelect: return OverlayEnv::kAmp; - case DeckParam::kPitchEnvSelect: return OverlayEnv::kPitch; - case DeckParam::kFilterEnvSelect: return OverlayEnv::kFilter; - default: return OverlayEnv::kNone; - } -} - -OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) { - const OverlayEnv picked = overlayEnvForRadio(radioId); - if (picked == OverlayEnv::kNone) return current; // not a radio: nothing selects - return (current == picked) ? OverlayEnv::kNone : picked; -} - -OverlayEnv overlayEnvForModeToggle(int toggleId) { - switch (static_cast(toggleId)) { - case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp; - case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch; - case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter; - default: return OverlayEnv::kNone; +OverlayEnv overlayEnvForGroup(int groupId) { + switch (groupId) { + case kGroupAmpEnv: return OverlayEnv::kAmp; + case kGroupPitchEnv: return OverlayEnv::kPitch; + case kGroupFilterEnv: return OverlayEnv::kFilter; + default: return OverlayEnv::kNone; } } diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index 7a977aa..0525666 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -72,7 +72,9 @@ enum class DeckParam { kAmpVelCurve, kPitchVelCurve, kFilterVelCurve, - // Overlay selection radios — transient view state, not parameters. + // Retired overlay-selection radios. The deck itself is the overlay's click target now, so + // no group carries these — they survive only because param/'s frozen tables enumerate the + // whole id space and must stay total. Do not re-add a radio for them. kAmpEnvSelect, kPitchEnvSelect, kFilterEnvSelect, @@ -201,14 +203,13 @@ LiveCommit liveCommitFor(LiveDragKind kind, int paramId); // never persisted, never a parameter. enum class OverlayEnv { kNone, kAmp, kPitch, kFilter }; -// The envelope a deck's overlay-select radio picks; kNone for any other control id. -OverlayEnv overlayEnvForRadio(int radioId); - -// The selection a click on `radioId` produces from `current`. Two rules, provable here rather -// than in the shell: picking another deck's radio switches to it (exclusivity), and clicking -// the ACTIVE one clears back to kNone — "no envelope shown" is a state the user can get back -// to, not an error. A non-radio id leaves the selection alone. -OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId); +// The selection a click inside deck group `groupId` produces — THE overlay focus map, and the +// whole state machine now that the deck is its own click target. An envelope deck names its +// envelope; every other group, and every point outside the deck (-1), names kNone, which is +// how a click on a control surface outside the envelope decks clears the focus. Exclusive and +// IDEMPOTENT by construction: re-clicking a focused deck re-selects it rather than toggling to +// none, so a knob tweak on the focused deck cannot unfocus it. +OverlayEnv overlayEnvForGroup(int groupId); // The group states the two inert predicates below read. One struct rather than a growing // parameter list, so adding a gate is a change at the two predicates and nowhere else. @@ -220,9 +221,6 @@ struct DeckEnableState { bool filterSpline = false; }; -// Which envelope a Staged|Spline mode toggle belongs to; kNone for any other control id. -OverlayEnv overlayEnvForModeToggle(int toggleId); - // Whether `env`'s deck group is switched on at all. Amp has no enable toggle and is always on. // The gate BOTH overlay modes share — a disabled group's contour is as dead as its knobs. bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state); diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index 87fe785..558c821 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -182,6 +182,17 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { enforceGateUnavailableWhileDrawn(play); } +int nextToggleSegment(DeckParam id, const PlaySeconds& play) { + switch (id) { + case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 0 : 1; + case DeckParam::kFilterEnable: return play.filter.enabled ? 0 : 1; + case DeckParam::kAmpEnvMode: return play.ampSpline.mode == EnvMode::Spline ? 0 : 1; + case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 0 : 1; + case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 0 : 1; + default: return -1; + } +} + // deckParamNorm and setDeckParam carry each id's MAP — which taper, which clamp; these two carry // only its LOCATION, which is the whole mechanism of the taper-free reset (see deck_values.h for // why they are exposed beyond that one caller). A toggle, radio or curve cell has no reset gesture diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index 3609ca2..eca0d9d 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -59,6 +59,13 @@ double storedFromNorm(DeckParam id, double norm); // Mutates `play` in place, touching exactly the one field the control names. void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment); +// The segment a SINGLE-BUTTON toggle commits: its NEXT state, derived from what `play` holds +// now, because a one-button control carries no segment of its own. This is what keeps +// setDeckParam's segment contract the only one there is rather than growing a second commit +// path beside it. Answers -1 for every control that is not a single-button parameter-set +// toggle — the segmented ones already carry the segment the user clicked. +int nextToggleSegment(DeckParam id, const PlaySeconds& play); + // Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no // second table of defaults to drift from the real one, and the value is COPIED rather than // round-tripped through norm -> value. That bypass is MANDATORY: a reset must land on the stored diff --git a/src/core/instrument/ui/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp index caf5dcf..468aae3 100644 --- a/src/core/instrument/ui/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -24,7 +24,7 @@ int knobRowWidth(const DeckGroupDesc& g) { int w = static_cast(g.cellIds.size()) * kDeckCellW; if (g.rowToggle.id >= 0) { if (w > 0) w += kDeckToggleGap; - w += 2 * g.rowToggle.segWidth; + w += g.rowToggle.width; } return w; } @@ -32,12 +32,34 @@ int knobRowWidth(const DeckGroupDesc& g) { // The caption-row width: the caption reserve plus the optional caption toggle and radio. int captionRowWidth(const DeckGroupDesc& g) { int w = g.captionWidth; - if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth; - if (g.captionToggle2.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle2.segWidth; + if (g.captionToggle.id >= 0) w += kDeckToggleGap + g.captionToggle.width; + if (g.captionToggle2.id >= 0) w += kDeckToggleGap + g.captionToggle2.width; if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize; return w; } +// One toggle inside [right - d.width, right) at `top`. A segmented toggle splits that span in +// two, the left half taking an odd pixel; a single-button style takes the whole span in seg0 +// and leaves seg1 empty, which is what makes its hit carry no segment. +DeckToggleLayout layoutToggle(const DeckToggleDesc& d, int right, int top) { + const Rect whole = Rect::ltrb(right - d.width, top, right, top + kDeckToggleH); + if (d.style != DeckToggleStyle::kSegmented) return {d.id, whole, Rect{}, d.style}; + const int split = whole.right() - d.width / 2; + return {d.id, Rect::ltrb(whole.x, top, split, whole.bottom()), + Rect::ltrb(split, top, whole.right(), whole.bottom()), d.style}; +} + +// The toggle segment a point lands on: 0/1 when segmented, -1 for the whole of a single +// button, and -2 for a miss (a real segment index cannot be negative, and a single button's +// own answer is already -1). +int toggleSegmentAt(const DeckToggleLayout& t, int x, int y) { + if (t.id < 0) return -2; + if (t.style != DeckToggleStyle::kSegmented) return contains(t.seg0, x, y) ? -1 : -2; + if (contains(t.seg0, x, y)) return 0; + if (contains(t.seg1, x, y)) return 1; + return -2; +} + // One knob cell inside `cell`: the centered dial square, its concentric inner disc, and the // label band beneath. DeckCellLayout layoutCell(int id, const Rect& cell) { @@ -81,12 +103,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2; const auto placeToggle = [&](const DeckToggleDesc& d, DeckToggleLayout& into) { if (d.id < 0) return; - const int segW = d.segWidth; - const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight, - togTop + kDeckToggleH); - const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH); - into = DeckToggleLayout{d.id, seg0, seg1}; - captionRight = seg0.x - kDeckToggleGap; + into = layoutToggle(d, captionRight, togTop); + captionRight -= d.width + kDeckToggleGap; // Caption text stops at the leftmost toggle: pull the right edge in (XYWH: width). out.caption.width = captionRight - out.caption.x; }; @@ -120,32 +138,30 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { return out; } - // 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. + // Knob row: every cell keeps its natural kDeckCellW and the RUN of them is CENTRED in the + // whole reserved width (one kDeckCellW per declared id, reserves included). That is what + // holds a group's width across a mode flip without stretching the knobs off their pitch — + // a face with no reserves gets the identical run either way. Do not reintroduce dividing + // the run between the cells present: it hands a reduced face 90-100 px cells. const int runWidth = static_cast(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; + int x = innerLeft + (runWidth - presentCells * kDeckCellW) / 2; for (int id : g.cellIds) { if (id < 0) continue; - out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW, + out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH))); - x += cellW; + x += kDeckCellW; } if (g.rowToggle.id >= 0) { - // Anchored past the whole reserved run, not past the last cell, so a residue margin + // Anchored past the whole reserved run, not past the last cell, so a centring 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(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}; + out.rowToggle = layoutToggle(g.rowToggle, tx + g.rowToggle.width, + cellTop + (kDeckCellH - kDeckToggleH) / 2); } return out; } @@ -254,32 +270,30 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { if (!contains(g.box, x, y)) continue; if (g.captionRadio.id >= 0 && !g.captionRadio.passive && contains(g.captionRadio.box, x, y)) { - return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false}; + return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false, g.id}; } for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) { - if (t->id < 0) continue; - if (contains(t->seg0, x, y)) return {DeckHitKind::CaptionToggle, t->id, 0}; - if (contains(t->seg1, x, y)) return {DeckHitKind::CaptionToggle, t->id, 1}; + const int seg = toggleSegmentAt(*t, x, y); + if (seg != -2) return {DeckHitKind::CaptionToggle, t->id, seg, false, g.id}; } - if (g.rowToggle.id >= 0) { - if (contains(g.rowToggle.seg0, x, y)) - return {DeckHitKind::RowToggle, g.rowToggle.id, 0}; - if (contains(g.rowToggle.seg1, x, y)) - return {DeckHitKind::RowToggle, g.rowToggle.id, 1}; + { + const int seg = toggleSegmentAt(g.rowToggle, x, y); + if (seg != -2) return {DeckHitKind::RowToggle, g.rowToggle.id, seg, false, g.id}; } for (const DeckCellLayout& c : g.cells) { // Every entry here already has a real id — a reserve yields no DeckCellLayout at all. if (contains(c.cell, x, y)) { - return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)}; + return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y), g.id}; } } if (g.column.id >= 0 && contains(g.column.box, x, y)) { - return {DeckHitKind::Column, g.column.id, -1, false}; + return {DeckHitKind::Column, g.column.id, -1, false, g.id}; } - // Inside the box but on fence/padding — a miss. First-match is exact while the boxes + // Inside the box but on fence/padding — a control miss that still names the group, so + // the deck panel's own background stays a target. First-match is exact while the boxes // are disjoint, which they are at every width the row block fits; under the sub-floor // overrun an overrunning row can reach the spanning deck and the row group answers. - return {}; + return {DeckHitKind::None, -1, -1, false, g.id}; } return {}; } diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index 3e7103e..aae1360 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -65,10 +65,19 @@ inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck // the whole width budget for the life of this layout (asserted in test_deck_groups.cpp) — see // instrument-control-surface.md §1.6 before spending any of it. -// A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent. +// What a click on a toggle MEANS, which is also what it draws as. +// kSegmented — two segments side by side; the click picks one, and the hit carries 0 or 1. +// kEnable — ONE button labelled with what it controls; on/off, the hit carries NO segment. +// kMode — ONE button whose LABEL reads the current mode; it has no off state. +// The two single-button styles carry no segment precisely so a commit cannot read one: the +// caller derives the next state from the current one (deck_values' nextToggleSegment). +enum class DeckToggleStyle { kSegmented, kEnable, kMode }; + +// A compact toggle. id -1 = absent. struct DeckToggleDesc { - int id = -1; // shell control id returned by the hit-test; -1 = no toggle - int segWidth = 44; // px per segment + int id = -1; // shell control id returned by the hit-test; -1 = no toggle + int width = 88; // the WHOLE control's px width, both styles — a segmented one halves it + DeckToggleStyle style = DeckToggleStyle::kSegmented; }; // A single-square corner radio (an exclusive selector across groups, so the group itself @@ -88,13 +97,14 @@ struct DeckColumnDesc { }; // One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 -// 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 one cell's WIDTH without a cell, and the cells present are CENTRED in the whole +// run at their natural kDeckCellW — see this module's CLAUDE.md bullet for what that buys. +// Only the reserve COUNT matters, never where a -1 sits in the list. `captionWidth` is the px +// the shell reserves for the caption text (this module does not measure text). // // A SPANNING group reads `cellIds` down instead of across: one FIXED kDeckCellW slot per -// declared id, at successive row baselines, reserves included. The run-division law above is -// horizontal only — applied vertically it would stretch a lone knob over the whole box. +// declared id, at successive row baselines, reserves included — and there a reserve's POSITION +// does matter, because it advances the slot. struct DeckGroupDesc { int id = 0; // shell group id (opaque here) int captionWidth = 60; @@ -114,8 +124,9 @@ struct DeckGroupDesc { struct DeckToggleLayout { int id = -1; - Rect seg0; // left segment - Rect seg1; // right segment + Rect seg0; // left segment; the WHOLE button under either single-button style + Rect seg1; // right segment; EMPTY under either single-button style + DeckToggleStyle style = DeckToggleStyle::kSegmented; }; struct DeckRadioLayout { @@ -190,8 +201,12 @@ enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Col struct DeckHit { DeckHitKind kind = DeckHitKind::None; int id = -1; // the control id of the hit element (cell id / toggle id / radio id) - int segment = -1; // 0/1 for a toggle hit; -1 otherwise + int segment = -1; // 0/1 for a SEGMENTED toggle hit; -1 otherwise, single buttons included bool inner = false; // Knob hits only: the grab landed on the cell's inner dial + // The group whose box contains the point, INDEPENDENT of `kind` — a click on a group's + // fence padding is a control miss but still names the deck it landed on, which is what + // lets the whole panel be a target. -1 only when the point is outside every group. + int group = -1; }; // The deck element a point lands on: a knob cell (the whole cell, not just the knob diff --git a/src/core/instrument/ui/sample_chrome.cpp b/src/core/instrument/ui/sample_chrome.cpp index 20c2004..9851178 100644 --- a/src/core/instrument/ui/sample_chrome.cpp +++ b/src/core/instrument/ui/sample_chrome.cpp @@ -17,12 +17,11 @@ constexpr int kToolbarHeight = 58; constexpr int kStripBandHeight = 30; constexpr int kRunGap = 6; // between adjacent items of the toolbar run -constexpr int kChanSegW = 52; -constexpr int kChanSegH = 18; -// The loop enable's segments carry a two-word label, so they are wider than Mono|Stereo's. -// If the title slot ever fails to hold its text at the editor's floor, THIS narrows — the +constexpr int kChanBtnW = 60; // fits the longer of the two mode labels ("Stereo") +constexpr int kChanBtnH = 18; // shared height of the Loop and channel-mode single buttons +// If the title slot ever fails to hold its text at the editor's floor, THESE narrow — the // floor does not move. -constexpr int kLoopSegW = 58; +constexpr int kLoopBtnW = 52; constexpr int kVelCellW = 56; constexpr int kHoldCellW = 56; // the bake Hold cell, same grammar as the velocity cell constexpr int kVelLabelH = 16; @@ -47,8 +46,7 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) { const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; }; const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); }; - // The fixed run, right to left: Browse, Mono|Stereo, Loop Off|On, velocity cell, preview, - // bake, hold. + // The fixed run, right to left: Browse, channel, loop, velocity cell, preview, bake, hold. // The velocity-curve button that used to sit here now lives in the deck's VELOCITY group. const int navH = std::min(kRunButtonH, row.height); const int navTop = topFor(navH); @@ -56,22 +54,18 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) { r.navBrowse = Rect::ltrb(leftOf(navRight, kNavButtonWidth), navTop, navRight, navTop + navH); - const int chanTop = topFor(kChanSegH); + const int chanTop = topFor(kChanBtnH); const int chanRight = leftOf(r.navBrowse.x, kRunGap); - r.chanStereo = Rect::ltrb(leftOf(chanRight, kChanSegW), chanTop, chanRight, - chanTop + kChanSegH); - r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x, - chanTop + kChanSegH); + r.channel = Rect::ltrb(leftOf(chanRight, kChanBtnW), chanTop, chanRight, + chanTop + kChanBtnH); - const int loopRight = leftOf(r.chanMono.x, kRunGap); - r.loopOn = Rect::ltrb(leftOf(loopRight, kLoopSegW), chanTop, loopRight, - chanTop + kChanSegH); - r.loopOff = Rect::ltrb(leftOf(r.loopOn.x, kLoopSegW), chanTop, r.loopOn.x, - chanTop + kChanSegH); + const int loopRight = leftOf(r.channel.x, kRunGap); + r.loop = Rect::ltrb(leftOf(loopRight, kLoopBtnW), chanTop, loopRight, + chanTop + kChanBtnH); const int cellH = std::min(row.height, knobSize + kVelLabelH); const int cellTop = topFor(cellH); - const int cellRight = leftOf(r.loopOff.x, kRunGap); + const int cellRight = leftOf(r.loop.x, kRunGap); r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH); const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2; r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize, diff --git a/src/core/instrument/ui/sample_chrome.h b/src/core/instrument/ui/sample_chrome.h index 28e684c..88627d6 100644 --- a/src/core/instrument/ui/sample_chrome.h +++ b/src/core/instrument/ui/sample_chrome.h @@ -33,13 +33,12 @@ struct ChromeRects { Rect velCell; // preview-velocity knob cell (knob + label band) Rect velKnob; Rect velLabel; - // The sustain loop's enable. Immediately left of the channel toggle because it is the same - // class of control — a playback mode of the loaded capture — and because the run is - // right-anchored, so the title slot absorbs its width and the editor's floor does not move. - Rect loopOff; - Rect loopOn; - Rect chanMono; - Rect chanStereo; + // The sustain loop's enable, ONE button. Immediately left of the channel selector because + // it is the same class of control — a playback mode of the loaded capture — and because the + // run is right-anchored, so the title slot absorbs its width and the editor's floor does + // not move. + Rect loop; + Rect channel; // the mono/stereo MODE selector: its label reads the current mode Rect navBrowse; Rect controls; // full-width second row Rect rootStrip; // the piano strip: the whole row, inset only by the shared band pad diff --git a/src/shell/instrument/editor_input.cpp b/src/shell/instrument/editor_input.cpp index 2bb32fd..94d7368 100644 --- a/src/shell/instrument/editor_input.cpp +++ b/src/shell/instrument/editor_input.cpp @@ -34,6 +34,16 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // Band order matters only where bands can overlap on a degenerate window; each branch // reports whether it consumed the click so the next band gets a clean shot. const FaceLayout fl = faceLayout(w, h); + // The overlay focus follows the deck (mouseDownDeck sets it), and clicking a control + // surface OUTSIDE the envelope decks clears it. The waveform band is deliberately not one + // of those surfaces: it IS the overlay, so editing what you selected must not deselect it. + // Chrome's own dead space (the title band, the gaps between its controls) is not a control + // surface either — hoverChrome answers only for chrome's actual interactive elements. + if (drag_ == DragKind::kNone && hoverChrome(fl, x, y).kind != HoverKind::kNone && + overlayEnv_ != OverlayEnv::kNone) { + overlayEnv_ = OverlayEnv::kNone; + invalidate(); // the chrome branches below repaint only what THEY changed + } if (mouseDownChrome(fl, x, y)) return; if (selectedId_.empty()) return; // empty state — chrome nav only if (mouseDownDeck(fl, x, y)) return; diff --git a/src/shell/instrument/editor_input_chrome.cpp b/src/shell/instrument/editor_input_chrome.cpp index e60cfad..bcfc12a 100644 --- a/src/shell/instrument/editor_input_chrome.cpp +++ b/src/shell/instrument/editor_input_chrome.cpp @@ -75,28 +75,19 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) { } // The loop enable. Inert (not hidden) outside Gate: that refusal comes from the engine and // no click can talk it out of it — unlike the user's own off, which the marks themselves - // still offer to reverse. - if (loopControlsLive()) { - if (contains(cr.loopOff, x, y)) { - setLoopEnabled(false); - invalidate(); - return true; - } - if (contains(cr.loopOn, x, y)) { - setLoopEnabled(true); - invalidate(); - return true; - } - } - if (contains(cr.chanMono, x, y)) { - channelMode_ = ChannelMode::Mono; - processor_->setChannelMode(ChannelMode::Mono); + // still offer to reverse. One button, so the click's meaning is "the other state": read the + // current enable off the markers, which is the same source the button's paint reads. + if (loopControlsLive() && contains(cr.loop, x, y)) { + // setLoopEnabled owns the frames <= 0 guard (empty capture) — no need to repeat it here. + const auto frames = static_cast(monoPcmFor(selectedId_).size()); + setLoopEnabled(!pickedMarkers(frames).hasLoop); invalidate(); return true; } - if (contains(cr.chanStereo, x, y)) { - channelMode_ = ChannelMode::Stereo; - processor_->setChannelMode(ChannelMode::Stereo); + if (contains(cr.channel, x, y)) { + channelMode_ = + (channelMode_ == ChannelMode::Stereo) ? ChannelMode::Mono : ChannelMode::Stereo; + processor_->setChannelMode(channelMode_); invalidate(); return true; } @@ -161,12 +152,8 @@ HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x, if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1}; if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1}; if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1}; - if (loopControlsLive()) { - if (contains(cr.loopOff, x, y)) return {HoverKind::kLoopOff, -1}; - if (contains(cr.loopOn, x, y)) return {HoverKind::kLoopOn, -1}; - } - if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1}; - if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1}; + if (loopControlsLive() && contains(cr.loop, x, y)) return {HoverKind::kLoop, -1}; + if (contains(cr.channel, x, y)) return {HoverKind::kChannel, -1}; if (!cr.rootStrip.empty()) { const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y); diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index 8e19644..cf1f24f 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -29,10 +29,20 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width); const DeckHit hit = hitTestDeck(dl, x, y); - if (hit.kind == DeckHitKind::CaptionRadio) { - overlayEnv_ = nextOverlaySelection(overlayEnv_, hit.id); - invalidate(); // view state only: no parameter write, no reload - return true; + // The deck IS the overlay's click target: anywhere inside an envelope group — panel + // background, knob or button — focuses that envelope, and any other group clears it. Set + // BEFORE the kind switch and it consumes nothing, so every grab and commit below still + // runs. A drag in flight owns the surface, so it refuses the change — always true at this + // call site today (mouse capture makes a real WM_LBUTTONDOWN-while-dragging unreachable; + // WM_CAPTURECHANGED and WM_LBUTTONUP both reset drag_ before another down can land), but + // left explicit rather than assumed so a future capture-handling change fails loud, not + // by silently letting a drag's own surface steal its own focus mid-gesture. + if (drag_ == DragKind::kNone) { + const OverlayEnv focus = overlayEnvForGroup(hit.group); + if (focus != overlayEnv_) { + overlayEnv_ = focus; + invalidate(); // view state only: no parameter write, no reload + } } if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { switch (static_cast(hit.id)) { @@ -65,29 +75,29 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { commitAndReload(); break; case ParamControl::kLimiterEnable: { - const bool on = (hit.segment == 1); - if (on != params_.limiterEnabled) { - params_.limiterEnabled = on; - // Commits the audible state and the persisted state together, here, because - // this is a control the user A/Bs. The funnel only ARMS the host's latency - // restart — the sync tick delivers it — so nothing on this path calls into - // the host from inside a mouse handler. - processor_->setLimiterEnabled(on); - } + // One button, so its next state is the opposite of the current one — which is + // also why the old "did it actually change" guard is gone: it always does. + const bool on = !params_.limiterEnabled; + params_.limiterEnabled = on; + // Commits the audible state and the persisted state together, here, because + // this is a control the user A/Bs. The funnel only ARMS the host's latency + // restart — the sync tick delivers it — so nothing on this path calls into + // the host from inside a mouse handler. + processor_->setLimiterEnabled(on); invalidate(); break; } default: { // Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable, - // and the three env-mode toggles). - applyParamControl(hit.id, 0.0, hit.segment); + // and the three env-mode selectors). A single button carries no segment, so + // its next state is derived from the parameter set rather than read off the + // click — deck_values owns that derivation. + const int segment = + hit.segment >= 0 + ? hit.segment + : nextToggleSegment(static_cast(hit.id), params_.play); + applyParamControl(hit.id, 0.0, segment); commitAndReload(); - // Flipping an EG's Staged|Spline toggle makes THAT envelope's overlay active, - // so the contour (or the staged shape you just returned to) is what's drawn. - // overlayEnvForModeToggle answers kNone for every other toggle this default - // case handles, which is why the assignment is conditional. - const OverlayEnv modeEnv = overlayEnvForModeToggle(hit.id); - if (modeEnv != OverlayEnv::kNone) overlayEnv_ = modeEnv; break; } } @@ -215,7 +225,6 @@ HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x, // The meter reports its own state continuously; a hover on it would only mean "the clip // cap is clearable", which the cap's presence already says. if (dh.kind == DeckHitKind::Column) return {}; - if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id}; if (dh.kind == DeckHitKind::Knob && dh.inner && curveParamFor(static_cast(dh.id)) != ParamControl::kCount) { // Indexed by the OUTER cell id so the paint side can find the cell it belongs to. diff --git a/src/shell/instrument/editor_interaction.h b/src/shell/instrument/editor_interaction.h index d06675b..848a3da 100644 --- a/src/shell/instrument/editor_interaction.h +++ b/src/shell/instrument/editor_interaction.h @@ -26,16 +26,13 @@ enum class HoverKind { kCard, // a capture card (index = visible_ index) kBrowseConfirm, // the Browse modal "Load" confirm button kBrowseCancel, // the Browse modal "Cancel" button - kChanMono, // the mono channel-mode segment - kChanStereo, // the stereo channel-mode segment - kLoopOff, // the loop enable's Off segment - kLoopOn, // the loop enable's On segment + kChannel, // the channel-mode selector button + kLoop, // the loop enable button kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label kPreview, // the preview-trigger button kBake, // the resample-bake trigger kControl, // a knob-deck element (index = control id) kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id) - kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id) kCurveNode, // a velocity-curve control point (index = point index) kVelKnob, // the chrome preview-velocity radial knob kHoldKnob, // the chrome bake-Hold radial knob diff --git a/src/shell/instrument/editor_paint_chrome.cpp b/src/shell/instrument/editor_paint_chrome.cpp index b5270e9..3ef2df0 100644 --- a/src/shell/instrument/editor_paint_chrome.cpp +++ b/src/shell/instrument/editor_paint_chrome.cpp @@ -1,6 +1,6 @@ // editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title + -// live readout, then the control run — preview, preview-velocity knob, Loop Off|On, -// Mono|Stereo, Browse) over the strip row, which the piano strip has to itself. Windows-only; +// live readout, then the control run — preview, preview-velocity knob, Loop, the channel mode, +// Browse) over the strip row, which the piano strip has to itself. Windows-only; // all rects come from the pure sample_chrome interior and the pure keyboard_strip geometry. #include "shell/instrument/reasampler_editor.h" @@ -212,44 +212,51 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool } } - // Loop Off | On. A two-segment toggle in the same primitive as Mono|Stereo because it is - // the same class of control: a playback mode of the loaded capture. Outside Gate both - // segments draw Disabled and neither accepts a click — the state is preserved, not cleared, - // so the return to Gate restores it. + // The two single-button chrome controls, in the deck's own grammar: Loop is an ENABLE + // (Primary on, dim gray off), the channel button is a MODE SELECTOR whose label reads the + // current mode and so has no off state. Outside Gate the loop button draws Disabled and + // accepts no click — a third state, not an off: the enable's own state is preserved, not + // cleared, so the return to Gate restores it. { - const bool live = loopControlsLive(); - const bool on = marks.hasLoop; - const auto segState = [&](bool active, HoverKind hk) { - if (!live) return InteractionState::Disabled; - if (active) return InteractionState::Active; - return isHovered(hk, -1) ? InteractionState::Hover : InteractionState::Rest; + const auto drawChromeToggle = [&](const Rect& r, const char* label, HoverKind hk, + bool active, bool disabled) { + const bool hov = !disabled && isHovered(hk, -1); + const InteractionState st = + disabled ? InteractionState::Disabled + : (active ? InteractionState::Active + : (hov ? InteractionState::Hover : InteractionState::Rest)); + fillSurface(bmp, toKitBox(r), Role::BgCell, st); + // Disabled's wash over bg/cell is barely a shade off Rest's — the third state needs + // its own mark, not just a slightly-quieter fill, so it draws the one outline this + // control ever gets. See PLAN.md's Off-vs-Disabled ruling: Off is live and + // clickable, Disabled is not, and the two must not read as the same thing. + // Outlined in text/dim rather than line/hairline: hairline over this fill composites + // to ~1.36:1, under the 3:1 state-indicator floor; text/dim clears it at ~5:1. + if (disabled) { + LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, + toLice(roleColor(Role::TextDim)), 1.0f, 0); + } + kitTextCentered(bmp, r, label, kToolbarFont, + active && !disabled ? Role::BgBase + : (hov ? Role::TextPrimary : Role::TextDim)); }; - const InteractionState offState = segState(!on, HoverKind::kLoopOff); - const InteractionState onState = segState(on, HoverKind::kLoopOn); - fillSurface(bmp, toKitBox(cr.loopOff), Role::BgCell, offState); - fillSurface(bmp, toKitBox(cr.loopOn), Role::BgCell, onState); - const Role dim = live ? Role::TextPrimary : Role::TextDim; - kitTextCentered(bmp, cr.loopOff, "Loop Off", kToolbarFont, - (live && !on) ? Role::BgBase : dim); - kitTextCentered(bmp, cr.loopOn, "Loop On", kToolbarFont, - (live && on) ? Role::BgBase : dim); - } + drawChromeToggle(cr.loop, "Loop", HoverKind::kLoop, marks.hasLoop, + !loopControlsLive()); - // Mono | Stereo output-mode toggle. - { + // The channel button is a MODE selector like the deck's Stage|Spline buttons — always + // "on", so InteractionState::Active would otherwise swallow every hover. Filled by + // accent/primary rather than bg/cell so Hover's mix-toward-accent/hot actually moves the + // surface instead of nudging bg/cell by a few percent (roleColorState's Active case + // always answers accent/primary regardless of the role passed in, so Rest is unreachable + // here and this is purely which color Hover mixes FROM). + const auto drawModeChromeToggle = [&](const Rect& r, const char* label, HoverKind hk) { + const bool hov = isHovered(hk, -1); + const InteractionState st = hov ? InteractionState::Hover : InteractionState::Active; + fillSurface(bmp, toKitBox(r), Role::AccentPrimary, st); + kitTextCentered(bmp, r, label, kToolbarFont, Role::BgBase); + }; const bool isStereo = (channelMode_ == ChannelMode::Stereo); - const InteractionState monoState = !isStereo ? InteractionState::Active - : (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover - : InteractionState::Rest); - const InteractionState stereoState = isStereo ? InteractionState::Active - : (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover - : InteractionState::Rest); - fillSurface(bmp, toKitBox(cr.chanMono), Role::BgCell, monoState); - fillSurface(bmp, toKitBox(cr.chanStereo), Role::BgCell, stereoState); - kitTextCentered(bmp, cr.chanMono, "Mono", kToolbarFont, - !isStereo ? Role::BgBase : Role::TextPrimary); - kitTextCentered(bmp, cr.chanStereo, "Stereo", kToolbarFont, - isStereo ? Role::BgBase : Role::TextPrimary); + drawModeChromeToggle(cr.channel, isStereo ? "Stereo" : "Mono", HoverKind::kChannel); } // The strip row: the full 128-key piano with the root lit. The loaded capture responds diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index d09134b..0015b99 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -162,6 +162,43 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { disabled ? Role::TextDim : (seg1Active ? Role::BgBase : Role::TextPrimary)); }; + // The single-button ENABLE form. Reads Primary on / dim gray off; a control the ENABLE + // gates draws Disabled, a third state and not a synonym for off (off is live and + // clickable). No deck ENABLE currently drives `disabled` true through this path — the + // deck's one Disabled control (FILTER's Band|Notch law when the filter itself is off) + // stays segmented and draws through `drawToggle` above — but the parameter stays for + // parity with that segmented form and because a future ENABLE could plausibly gate on + // something else the way Loop (chrome) already does outside this deck. + const auto drawButtonToggle = [&](const DeckToggleLayout& t, const char* label, bool active, + bool disabled) { + const bool hov = !disabled && isHovered(HoverKind::kControl, t.id); + // Hover outranks Active here (the reverse of the old order) so an enabled button still + // gets a hover cue — same gap drawModeToggle's comment above explains, just on the + // ENABLE form's own base role: mixing toward accent/hot FROM accent/primary (rather + // than bg/cell) is what makes the already-lit button visibly brighten on hover. + const InteractionState st = + disabled ? InteractionState::Disabled + : (hov ? InteractionState::Hover + : (active ? InteractionState::Active : InteractionState::Rest)); + fillSurface(bmp, toKitBox(t.seg0), active ? Role::AccentPrimary : Role::BgCell, st); + kitTextCentered(bmp, t.seg0, label, Font::Micro, + active && !disabled ? Role::BgBase + : (hov ? Role::TextPrimary : Role::TextDim)); + }; + // The single-button MODE form: the label reads the current mode, so there is no off state + // and no dim-gray rest — it is always "on". Filled by accent/primary rather than routed + // through drawButtonToggle (which would force InteractionState::Active regardless of hov, + // leaving MODE with no hover cue at all): Hover mixes toward accent/hot FROM whatever base + // roleColorState is handed, so passing accent/primary here is what lets the already-lit + // button visibly brighten on hover instead of a hover mix nobody would notice against + // bg/cell's dark base. + const auto drawModeToggle = [&](const DeckToggleLayout& t, EnvMode mode) { + const char* label = mode == EnvMode::Spline ? "Spline" : "Stage"; + const bool hov = isHovered(HoverKind::kControl, t.id); + const InteractionState st = hov ? InteractionState::Hover : InteractionState::Active; + fillSurface(bmp, toKitBox(t.seg0), Role::AccentPrimary, st); + kitTextCentered(bmp, t.seg0, label, Font::Micro, Role::BgBase); + }; const bool anySpline = splineActive(play); // The knob's short name label (swapped for the live value during hover/drag — no third @@ -209,10 +246,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { }; for (const DeckGroupLayout& g : dl.groups) { - // The fence: a bg/panel box with a hairline border, caption micro-caps left. + // The fence: a bg/panel box with a hairline border, caption micro-caps left. An + // envelope deck whose overlay is the one on the waveform takes the primary accent + // instead — the deck itself is the selection affordance, so the whole box says so. + const OverlayEnv groupEnv = overlayEnvForGroup(g.id); + const bool focused = groupEnv != OverlayEnv::kNone && groupEnv == overlayEnv_; fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest); LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1, - hairline, 1.0f, 0); + focused ? toLice(roleColor(Role::AccentPrimary)) : hairline, 1.0f, 0); const char* caption = ""; switch (g.id) { case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; @@ -227,8 +268,9 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { } kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); - // The gain-reduction lamp. ROUND, where the overlay radios in this same slot are - // square, so it reads as a lamp rather than a control. + // The gain-reduction lamp — MASTER's own corner slot, the one the env decks' overlay + // radios used to share before focus-by-click replaced them. ROUND, not square, so it + // reads as a passive readout rather than a control. if (g.captionRadio.id >= 0 && g.captionRadio.passive) { const Rect& rb = g.captionRadio.box; const float r = rb.width / 2.0f - 0.5f; @@ -237,26 +279,6 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { : Role::LineHairline)), 1.0f, 0, true); } - // The overlay-select radio: filled in the tertiary accent (the colour the overlay - // traces in) when this group's envelope is the one on the waveform, hollow otherwise. - if (g.captionRadio.id >= 0 && !g.captionRadio.passive) { - // overlayEnvForRadio returns kNone for BOTH "not a radio id" and "no selection" — - // a non-radio id must never read as lit just because nothing is selected, so the - // picked env has to be checked against kNone itself, not just matched by equality. - const OverlayEnv picked = overlayEnvForRadio(g.captionRadio.id); - const bool on = picked != OverlayEnv::kNone && overlayEnv_ == picked; - const bool hov = isHovered(HoverKind::kEnvRadio, g.captionRadio.id); - const Rect& rb = g.captionRadio.box; - LICE_DrawRect(bmp, rb.x, rb.y, rb.width - 1, rb.height - 1, - toLice(roleColor(on || hov ? Role::AccentTertiary - : Role::LineHairline)), - 1.0f, 0); - if (on) { - LICE_FillRect(bmp, rb.x + 3, rb.y + 3, rb.width - 6, rb.height - 6, - toLice(roleColor(Role::AccentTertiary)), 1.0f, 0); - } - } - // The compact caption toggles (right-anchored in the caption row, never full-width). for (const DeckToggleLayout* tp : {&g.captionToggle, &g.captionToggle2}) { if (tp->id < 0) continue; @@ -271,13 +293,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { play.pitchEngine == PitchEngine::Preserve, false); break; case ParamControl::kPitchEnvEnable: - drawToggle(t, "Off", "On", play.pitchEnv.enabled, false); + drawButtonToggle(t, "Envelope", play.pitchEnv.enabled, false); break; case ParamControl::kVoiceMode: drawToggle(t, "Poly", "Mono", isMono, false); break; case ParamControl::kFilterEnable: - drawToggle(t, "Off", "On", play.filter.enabled, false); + drawButtonToggle(t, "Filter", play.filter.enabled, false); break; case ParamControl::kFilterLaw: drawToggle(t, "Band", "Notch", @@ -286,16 +308,18 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { !play.filter.enabled); break; case ParamControl::kLimiterEnable: - drawToggle(t, "Off", "On", params_.limiterEnabled, false); + drawButtonToggle(t, "Limiter", params_.limiterEnabled, false); break; + // A mode SELECTOR: the label is the state, so it is drawn Active either way — + // there is nothing here for a dim-gray off to mean. case ParamControl::kAmpEnvMode: - drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false); + drawModeToggle(t, play.ampSpline.mode); break; case ParamControl::kPitchEnvMode: - drawToggle(t, "Stg", "Spl", play.pitchSpline.mode == EnvMode::Spline, false); + drawModeToggle(t, play.pitchSpline.mode); break; case ParamControl::kFilterEnvMode: - drawToggle(t, "Stg", "Spl", play.filterSpline.mode == EnvMode::Spline, false); + drawModeToggle(t, play.filterSpline.mode); break; default: break; } diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 88276d6..ea7dae8 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -125,11 +125,10 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() { const std::vector 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 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 g = sampleDeckGroups(mode); - int radios = 0; + const DeckGroupDesc& fe = g[static_cast(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(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 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(indexOfGroup(trig, kGroupFilterEnv))]; const std::vector 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 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(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(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 gate = sampleDeckGroups(PlayMode::Gate); + const std::vector 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(indexOfGroup(gate, id))]; + const DeckGroupLayout& b = tl.groups[static_cast(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(a.cells.size() - b.cells.size()) * kDeckCellW); + } +} static void testHitTestResolvesTheNewFilterControls() { const std::vector 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(indexOfGroup(sampleDeckGroups(PlayMode::Gate), kGroupAmpEnv))]; @@ -457,7 +553,8 @@ static void testGateSplineGateRoundTripsToTheSameLayout() { drawn.groups[static_cast(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(); diff --git a/tests/test_deck_groups_measured.cpp b/tests/test_deck_groups_measured.cpp index 1bb28bd..dd71071 100644 --- a/tests/test_deck_groups_measured.cpp +++ b/tests/test_deck_groups_measured.cpp @@ -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 g = sampleDeckGroups(mode); @@ -101,9 +101,9 @@ static void testBothRowsAndTheSpanningDeckFitTheBudget() { const int spanning = static_cast(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 g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); const auto box = [&](int id) { return dl.groups[static_cast(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(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 g = sampleDeckGroups(PlayMode::Gate); + const DeckGroupDesc& m = g[static_cast(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 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 g = sampleDeckGroups(PlayMode::Gate); DeckGroupDesc probe = g[static_cast(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 g = sampleDeckGroups(PlayMode::Gate); const DeckGroupDesc& penv = g[static_cast(indexOfGroup(g, kGroupPitchEnv))]; const DeckGroupDesc& amp = g[static_cast(indexOfGroup(g, kGroupAmpEnv))]; @@ -335,26 +405,29 @@ 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 // (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the -// reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the -// contour row's 876 a constant rather than a Gate-only fact. +// reserve slots hold the two mode-dependent groups at 372 (FILTER ENV) and 312 (AMP ENVELOPE) +// either way, which is what makes the contour row's 936 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,77 @@ static void testEveryGroupWidthMatchesTheMeasuredLayout() { dl.groups[static_cast(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 g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + const auto lay = [&](int id) -> const DeckGroupLayout& { + return dl.groups[static_cast(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 + {kGroupMaster, 1040, 142, 1}, // unchanged — the claim above actually pins it + }; + 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(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; diff --git a/tests/test_deck_groups_state.cpp b/tests/test_deck_groups_state.cpp index 0fda436..87bfff6 100644 --- a/tests/test_deck_groups_state.cpp +++ b/tests/test_deck_groups_state.cpp @@ -120,47 +120,58 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() { LiveCommit::Reload); } -// --- The overlay selection state machine --------------------------------------- +// --- The overlay focus state machine ------------------------------------------- static int radio(DeckParam p) { return static_cast(p); } -// EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never -// be overlay-active at once, whatever the previous selection was. -static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() { - const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch, - OverlayEnv::kFilter}; - for (OverlayEnv from : states) { - if (from != OverlayEnv::kAmp) { - CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp); - } - if (from != OverlayEnv::kPitch) { - CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) == - OverlayEnv::kPitch); - } - if (from != OverlayEnv::kFilter) { - CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) == - OverlayEnv::kFilter); +// EXCLUSIVITY, and the whole of it: the focus is a function of the clicked GROUP alone, so +// wherever it was before, clicking an envelope deck lands on that deck's envelope. Two +// envelopes can never be overlay-active at once, and no previous state can change the answer. +static void testOverlayFocusIsExclusiveAndIndependentOfThePreviousSelection() { + const struct { int group; OverlayEnv env; } decks[] = { + {kGroupAmpEnv, OverlayEnv::kAmp}, + {kGroupPitchEnv, OverlayEnv::kPitch}, + {kGroupFilterEnv, OverlayEnv::kFilter}, + }; + for (const auto& d : decks) CHECK(overlayEnvForGroup(d.group) == d.env); + // Distinct answers, so no two decks can select the same overlay. + CHECK(overlayEnvForGroup(kGroupAmpEnv) != overlayEnvForGroup(kGroupPitchEnv)); + CHECK(overlayEnvForGroup(kGroupPitchEnv) != overlayEnvForGroup(kGroupFilterEnv)); + CHECK(overlayEnvForGroup(kGroupAmpEnv) != overlayEnvForGroup(kGroupFilterEnv)); +} + +// Focus SETS; it does not toggle. Driven as the SHELL drives it — `focus = f(group)` over a +// click sequence starting from every prior focus — because that composition is the thing the +// retired re-click-clears branch broke: a second click on the focused deck (which is every +// knob tweak on it) landed back on kNone. The map taking no current focus is what makes that +// unreachable; this pins the sequence a reader would otherwise have to reconstruct. +static void testAClickSequenceOnOneDeckNeverLeavesIt() { + for (OverlayEnv prior : {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch, + OverlayEnv::kFilter}) { + OverlayEnv focus = prior; + // Panel, then knob, then button — all three land in the same group, so all three are + // the same assignment, whatever the click before them was. + for (int i = 0; i < 3; ++i) { + focus = overlayEnvForGroup(kGroupFilterEnv); + CHECK(focus == OverlayEnv::kFilter); } + // And leaving is a click ELSEWHERE, never a repeat of the one that got here. + focus = overlayEnvForGroup(kGroupVoice); + CHECK(focus == OverlayEnv::kNone); } } -// kNone is a RESTING STATE the user can get back to: clicking the active radio clears it. -static void testClickingTheActiveOverlayRadioClearsToNone() { - CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) == - OverlayEnv::kNone); - CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) == - OverlayEnv::kNone); - CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) == - OverlayEnv::kNone); -} - -// A control that is not one of the three radios selects nothing and clears nothing. -static void testANonRadioIdLeavesTheOverlaySelectionAlone() { - CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone); - CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone); - CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) == - OverlayEnv::kFilter); - CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp); +// kNone is still a reachable resting state — reached by clicking a control surface OUTSIDE the +// envelope decks rather than by clicking the active one again. +static void testClickingAnyNonEnvelopeDeckClearsTheFocus() { + for (int id : {kGroupPitch, kGroupFilter, kGroupVelocity, kGroupVoice, kGroupMaster}) { + CHECK(overlayEnvForGroup(id) == OverlayEnv::kNone); + } + CHECK(overlayEnvForGroup(-1) == OverlayEnv::kNone); // off the deck entirely + // A CONTROL id is not a group id: the map keys on groups now, and a stray control id must + // never light an overlay by numeric coincidence. + CHECK(overlayEnvForGroup(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone); + CHECK(overlayEnvForGroup(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone); } // The two group gates, spelled the way the predicates read them. Spline flags default off, so @@ -215,25 +226,22 @@ static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() { // A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs // that scale either shape stay live. (Which segment knobs, per envelope, is pinned in // spline_egs_tests alongside the rest of the spline rules.) -static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() { +static void testAModeToggleIsNotALiveControl() { CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload); CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload); CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload); - CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp); - CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch); - CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter); - // A mode toggle must not be mistaken for the overlay-select radio beside it. - CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone); - CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone); + // It needs no overlay map of its own: the toggle sits INSIDE its envelope's deck, so the + // click that flips it already focuses that envelope through the group map. + CHECK(overlayEnvForGroup(kGroupAmpEnv) == OverlayEnv::kAmp); } int main() { - testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks(); - testClickingTheActiveOverlayRadioClearsToNone(); - testANonRadioIdLeavesTheOverlaySelectionAlone(); + testOverlayFocusIsExclusiveAndIndependentOfThePreviousSelection(); + testAClickSequenceOnOneDeckNeverLeavesIt(); + testClickingAnyNonEnvelopeDeckClearsTheFocus(); testOverlayIsInertExactlyWhenItsGroupToggleIsOff(); testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); - testAModeToggleIsNeitherLiveNorAnOverlayRadio(); + testAModeToggleIsNotALiveControl(); testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers(); testOnlyALiveControlsDragTakesTheLiveTier(); if (g_fail == 0) std::printf("deck_groups_state: all tests passed\n"); diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index faedc2d..09ccadb 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -6,6 +6,7 @@ #include "../src/core/instrument/ui/deck_values.h" #include "../src/core/instrument/engine/master_gain.h" +#include "../src/core/instrument/ui/deck_groups.h" #include #include @@ -400,8 +401,84 @@ static void testTheFilterFourKeepTheirIdentityTaper() { } } +// The single-button commit seam. A one-button toggle carries no segment, so the commit derives +// the NEXT state from the parameter set and hands it to setDeckParam's unchanged segment +// contract. Driven end-to-end — derive, apply, re-derive — because the property that matters is +// that repeated clicks alternate the stored field rather than latching it. +static void testASingleButtonsDerivedSegmentFlipsTheFieldItNames() { + PlaySeconds p; + // Enables: off by default, so the first derived segment must be ON. + CHECK(!p.pitchEnv.enabled); + CHECK(nextToggleSegment(DeckParam::kPitchEnvEnable, p) == 1); + setDeckParam(DeckParam::kPitchEnvEnable, p, 0.0, + nextToggleSegment(DeckParam::kPitchEnvEnable, p)); + CHECK(p.pitchEnv.enabled); + CHECK(nextToggleSegment(DeckParam::kPitchEnvEnable, p) == 0); + setDeckParam(DeckParam::kPitchEnvEnable, p, 0.0, + nextToggleSegment(DeckParam::kPitchEnvEnable, p)); + CHECK(!p.pitchEnv.enabled); + + CHECK(!p.filter.enabled); + CHECK(nextToggleSegment(DeckParam::kFilterEnable, p) == 1); + setDeckParam(DeckParam::kFilterEnable, p, 0.0, + nextToggleSegment(DeckParam::kFilterEnable, p)); + CHECK(p.filter.enabled); + + // Mode selectors: Staged by default, so the first derived segment is Spline. Flipping the + // amp to Spline also forces Trigger (the drawn-EG rule), which is setDeckParam's own job + // and must survive the derived segment reaching it unchanged. + CHECK(p.ampSpline.mode == EnvMode::Staged); + CHECK(nextToggleSegment(DeckParam::kAmpEnvMode, p) == 1); + setDeckParam(DeckParam::kAmpEnvMode, p, 0.0, nextToggleSegment(DeckParam::kAmpEnvMode, p)); + CHECK(p.ampSpline.mode == EnvMode::Spline); + CHECK(p.playMode == PlayMode::Trigger); + CHECK(nextToggleSegment(DeckParam::kAmpEnvMode, p) == 0); + setDeckParam(DeckParam::kAmpEnvMode, p, 0.0, nextToggleSegment(DeckParam::kAmpEnvMode, p)); + CHECK(p.ampSpline.mode == EnvMode::Staged); + + for (DeckParam id : {DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode}) { + setDeckParam(id, p, 0.0, nextToggleSegment(id, p)); + } + CHECK(p.pitchSpline.mode == EnvMode::Spline); + CHECK(p.filterSpline.mode == EnvMode::Spline); + + // Every control that still carries its own segment answers "not mine", so the shell can + // tell the two commit paths apart on the answer alone. + for (DeckParam id : {DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kFilterLaw, + DeckParam::kVoiceMode, DeckParam::kMonoTrigger, + DeckParam::kFilterCutoff, DeckParam::kCount}) { + CHECK(nextToggleSegment(id, p) == -1); + } +} + +// The cross-check the hand-maintained list above cannot catch: a control RE-STYLED to a +// single button (kEnable/kMode) with no nextToggleSegment entry silently commits segment -1, +// which setDeckParam reads as "off" — a latch, not a toggle. Swept over every group +// sampleDeckGroups actually ships, in both play modes, rather than a fixed id list, so a +// future re-style is caught the moment it lands here with no entry above. kLimiterEnable is +// the one shipped kEnable that is excluded: it lives on `InstrumentParams::limiterEnabled`, +// outside `PlaySeconds`, and commits through its own handler (editor_input_deck.cpp) rather +// than through nextToggleSegment/setDeckParam at all. +static void testEveryShippedSingleButtonToggleHasADerivedSegment() { + const PlaySeconds p; + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + for (const DeckGroupDesc& g : sampleDeckGroups(mode)) { + for (const DeckToggleDesc* t : {&g.captionToggle, &g.captionToggle2, &g.rowToggle}) { + if (t->id < 0) continue; + if (t->style != DeckToggleStyle::kEnable && t->style != DeckToggleStyle::kMode) + continue; + const DeckParam id = static_cast(t->id); + if (id == DeckParam::kLimiterEnable) continue; + CHECK(nextToggleSegment(id, p) != -1); + } + } + } +} + int main() { testTheTwoCeilingNamesAreOneNumber(); + testASingleButtonsDerivedSegmentFlipsTheFieldItNames(); + testEveryShippedSingleButtonToggleHasADerivedSegment(); testNormRoundTripsThroughEveryValueDomain(); testRateKnobEndsAreTheStretchersOwnBounds(); testRateAndPitchBindTheirOwnFields(); diff --git a/tests/test_knob_deck.cpp b/tests/test_knob_deck.cpp index 2cf5dbd..1ee10cb 100644 --- a/tests/test_knob_deck.cpp +++ b/tests/test_knob_deck.cpp @@ -4,11 +4,13 @@ // * 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 abutting left-to-right // 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. +// * reserves — a -1 id holds the group's width and pays for it in the two end margins, with +// the run of present cells centred at their natural width. // * rows — membership comes from the group's own DeckRow, never from a wrap outcome; // space-between justification inside the row block; the right-anchored spanning deck. -// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding -// misses, outside-deck misses. +// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, the single-button +// styles' no-segment answer, the group id every hit carries, 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. @@ -30,25 +32,30 @@ static int g_fail = 0; // toggle + row toggle), MASTER (1 cell, no toggle). static std::vector shellLikeDeck() { std::vector g; - g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}}); - g.push_back({1, 38, {}, {101, 48}, {}, {6}, {}}); - g.push_back({2, 58, {}, {102, 32}, {}, {7, 8, 9}, {}}); - g.push_back({3, 38, {}, {103, 40}, {}, {10}, {104, 44}}); + g.push_back({0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}}); + g.push_back({1, 38, {}, {101, 96}, {}, {6}, {}}); + g.push_back({2, 58, {}, {102, 64}, {}, {7, 8, 9}, {}}); + g.push_back({3, 38, {}, {103, 80}, {}, {10}, {104, 88}}); g.push_back({4, 46, {}, {}, {}, {11}, {}}); return g; } static void testGroupWidth() { - // Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6. - DeckGroupDesc amp{0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}}; + // Knob row dominates: 5 cells (300) > caption row (78 + 4 + 88 = 170) -> 300 + 2*6. + DeckGroupDesc amp{0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}}; CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX); - // Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12. - DeckGroupDesc pitch{1, 38, {}, {101, 48}, {}, {6}, {}}; - CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX); - // Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122. - DeckGroupDesc voice{3, 38, {}, {103, 40}, {}, {10}, {104, 44}}; + // Caption row dominates: 38 + 4 + 96 = 138 > 60 -> 138 + 12. + DeckGroupDesc pitch{1, 38, {}, {101, 96}, {}, {6}, {}}; + CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 96 + 2 * kDeckGroupPadX); + // Row toggle counts into the knob row: 60 + 4 + 88 = 152 > caption 38+4+80=122. + DeckGroupDesc voice{3, 38, {}, {103, 80}, {}, {10}, {104, 88}}; CHECK(deckGroupWidth(voice) == - kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX); + kDeckCellW + kDeckToggleGap + 88 + 2 * kDeckGroupPadX); + // A toggle's `width` is the WHOLE control either way, so a single button and a segmented + // one of the same declared width cost the group exactly the same. + DeckGroupDesc single = voice; + single.captionToggle.style = DeckToggleStyle::kEnable; + CHECK(deckGroupWidth(single) == deckGroupWidth(voice)); // No toggles: max(caption, cells) + padding. DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}}; CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX); @@ -64,11 +71,11 @@ static void testGroupWidth() { // widths: two Sound groups, two Contour groups, one Spanning group carrying a column. static std::vector tworowDeck() { std::vector g; - g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}}); - g.push_back({1, 38, {}, {101, 48}, {}, {6, 7}, {}, DeckRow::Sound, {}}); - g.push_back({2, 58, {}, {102, 32}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}}); - g.push_back({3, 38, {}, {103, 40}, {}, {11}, {}, DeckRow::Contour, {}}); - g.push_back({4, 46, {200, true}, {104, 32}, {}, {12, -1}, {}, + g.push_back({0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}}); + g.push_back({1, 38, {}, {101, 96}, {}, {6, 7}, {}, DeckRow::Sound, {}}); + g.push_back({2, 58, {}, {102, 64}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}}); + g.push_back({3, 38, {}, {103, 80}, {}, {11}, {}, DeckRow::Contour, {}}); + g.push_back({4, 46, {200, true}, {104, 64}, {}, {12, -1}, {}, DeckRow::Spanning, {300, 62}}); return g; } @@ -291,41 +298,101 @@ static void testHitTest() { h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1); CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1); - // A reserve (id -1) yields no cell of its own. This fixture's reserve divides its present - // cells evenly (5 slots / 3 present -> 240/3, no residue), so every point of the knob row - // lands on a real control: no dead rect survives for a grab to fall into. That does NOT - // generalize to an indivisible reserve — a residue leaves a few uncovered margin pixels by - // design (testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds, below). + // A reserve (id -1) yields no cell of its own, and the cells present cover their CENTRED + // run contiguously — no dead rect between them for a grab to fall into. What the reserve + // buys is margin at the two ends, which is a deliberate miss and pinned as one below. std::vector trig; - trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}}); + trig.push_back({0, 78, {}, {100, 88}, {}, {20, 21, 22, -1, -1}, {}}); const DeckLayout tl = layoutDeck(trig, 0, 0, 824); const DeckGroupLayout& tg = tl.groups[0]; CHECK(tg.cells.size() == 3); for (const DeckCellLayout& c : tg.cells) CHECK(c.id >= 0); - // Bound the sweep against the RESERVED run (5 slots, not the 3 present cells) rather than - // the cells' own extent — the cells are what's under test, so deriving the bound from them - // could never catch a layout that under-covers the run they were reserved out of. - const int runStart = tg.box.x + kDeckGroupPadX; - const int runEnd = runStart + static_cast(trig[0].cellIds.size()) * kDeckCellW; const int rowY = tg.cells.back().cell.y + 5; - for (int px = runStart; px < runEnd; ++px) { + for (int px = tg.cells.front().cell.x; px < tg.cells.back().cell.right(); ++px) { const DeckHit rowHit = hitTestDeck(tl, px, rowY); CHECK(rowHit.kind == DeckHitKind::Knob && rowHit.id >= 0); } + // The reserve's own pixels answer no control — but they still name the group, which is + // what makes the deck panel's background a target for the overlay focus. + const DeckHit margin = hitTestDeck(tl, tg.box.x + kDeckGroupPadX + 1, rowY); + CHECK(margin.kind == DeckHitKind::None && margin.group == 0); - // The fence padding inside the box misses; outside the deck misses. + // The fence padding inside the box misses as a control and names its group; outside the + // deck misses entirely, group included. h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1); - CHECK(h.kind == DeckHitKind::None); + CHECK(h.kind == DeckHitKind::None && h.id == -1 && h.group == 0); h = hitTestDeck(dl, -50, -50); - CHECK(h.kind == DeckHitKind::None); + CHECK(h.kind == DeckHitKind::None && h.group == -1); + + // Every hit kind carries the group it landed in, so the shell never has to re-scan the + // layout to find out which deck a click belongs to. + CHECK(hitTestDeck(dl, c0.cell.x + 1, c0.cell.y + 1).group == 0); + CHECK(hitTestDeck(dl, amp.captionToggle.seg1.x, amp.captionToggle.seg1.y + 1).group == 0); + CHECK(hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1).group == 3); } -// 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}, {}}; +// A single-button toggle takes the WHOLE declared width in seg0, leaves seg1 empty, and — the +// property the commit seam rests on — answers with NO segment, so a caller cannot mistake it +// for the left half of a two-segment control. +static void testSingleButtonToggleTakesTheWholeSlotAndCarriesNoSegment() { + for (DeckToggleStyle style : {DeckToggleStyle::kEnable, DeckToggleStyle::kMode}) { + DeckGroupDesc g{0, 40, {}, {200, 52, style}, {}, {1, 2, 3}, {}}; + std::vector gs{g}; + const DeckLayout dl = layoutDeck(gs, 0, 0, 400); + const DeckToggleLayout& t = dl.groups[0].captionToggle; + CHECK(t.id == 200); + CHECK(t.style == style); + CHECK(t.seg0.width == 52); + CHECK(t.seg1.empty()); + // Right-anchored in the caption row exactly as a segmented toggle is. + CHECK(t.seg0.right() == dl.groups[0].box.right() - kDeckGroupPadX); + CHECK(t.seg0.height == kDeckToggleH); + + // Both ends of the button answer the same hit, with segment -1. + for (int px : {t.seg0.x, t.seg0.x + 26, t.seg0.right() - 1}) { + const DeckHit h = hitTestDeck(dl, px, t.seg0.y + 1); + CHECK(h.kind == DeckHitKind::CaptionToggle); + CHECK(h.id == 200 && h.segment == -1 && h.group == 0); + } + // Where the right half of a segmented toggle would have been is now the same button, + // not segment 1 — the regression this style exists to make impossible. + CHECK(hitTestDeck(dl, t.seg0.right() - 1, t.seg0.y + 1).segment != 1); + } +} + +// The caption toggles sit in the caption row and the knob circles in the cell row, so no +// button rect can overlap a dial. hitTestKnobFace runs NO toggle-precedence pass, and this is +// the property that lets it get away with that — re-checked here because the single-button +// styles made every one of those rects wider. +static void testNoToggleRectOverlapsAKnobCircle() { + DeckGroupDesc g{0, 40, {}, {200, 96, DeckToggleStyle::kEnable}, + {201, 96, DeckToggleStyle::kMode}, {1, 2, 3}, {202, 96}}; + std::vector gs{g}; + const DeckLayout dl = layoutDeck(gs, 0, 0, 600); + const DeckGroupLayout& lay = dl.groups[0]; + const DeckToggleLayout* toggles[] = {&lay.captionToggle, &lay.captionToggle2, + &lay.rowToggle}; + for (const DeckToggleLayout* t : toggles) { + for (const Rect& seg : {t->seg0, t->seg1}) { + if (seg.empty()) continue; + for (const DeckCellLayout& c : lay.cells) { + // Sweep the segment's own pixels: none of them may land on a drawn dial. + for (int px = seg.x; px < seg.right(); ++px) { + for (int py = seg.y; py < seg.bottom(); ++py) { + CHECK(!inKnobFace(c.knob, px, py)); + } + } + } + } + } +} + +// A reserve holds the group's WIDTH and gives its pixels to the two END MARGINS, never to the +// cells: every cell keeps kDeckCellW whatever face the group is showing, and the run of them is +// centred. That is the whole spacing law — a reduced face is the same knobs at the same pitch, +// sitting in the middle of a box that did not move. +static void testAReserveCentresTheRunAndNeverWidensACell() { + const DeckGroupDesc full{0, 78, {}, {100, 88}, {}, {20, 21, 22, 23, 24}, {}}; // Three, four, and a lone cell against the same five-slot reserve. const std::vector> faces = { {20, 21, 22, -1, -1}, {20, 21, 22, 23, -1}, {20, -1, -1, -1, -1}}; @@ -340,30 +407,42 @@ static void testReservedCellWidthGoesToTheCellsPresent() { const int present = static_cast(lay.cells.size()); CHECK(present == 5 - static_cast(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(i)]; - CHECK(c.cell.width == lay.cells[0].cell.width); // uniform - CHECK(c.knob.width == kDeckKnobSize); // the dial itself is fixed - // 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); + CHECK(c.cell.width == kDeckCellW); // natural pitch, never the divided run + CHECK(c.knob.width == kDeckKnobSize); + // The dial sits centred in its cell — 60 and 40 are both even, so exactly so. + CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right()); if (i > 0) CHECK(c.cell.x == lay.cells[static_cast(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); + // What the run does not cover is the reserve, split evenly at the two ends. The + // reserve is a whole number of 60px cells, so the split is exact — never off by one. + const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX); + const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right(); + CHECK(leadPad == trailPad); + CHECK(leadPad + trailPad == (5 - present) * kDeckCellW); + } + + // Only the reserve COUNT matters, not where a -1 sits: with the run centred, three faces + // that reserve two slots in three different places lay out identically. + const std::vector> sameCount = { + {20, 21, 22, -1, -1}, {-1, 20, 21, -1, 22}, {-1, -1, 20, 21, 22}}; + std::vector firstRun; + for (const std::vector& ids : sameCount) { + DeckGroupDesc d = full; + d.cellIds = ids; + std::vector g{d}; + const DeckLayout dl = layoutDeck(g, 0, 0, 824); + std::vector cells; + for (const DeckCellLayout& c : dl.groups[0].cells) cells.push_back(c.cell); + CHECK(cells.size() == 3); + if (firstRun.empty()) firstRun = cells; + else CHECK(cells == firstRun); } // 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}}; + DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 88}}; std::vector a{withToggle}; withToggle.cellIds = {20, 21, -1, -1, -1}; std::vector b{withToggle}; @@ -371,35 +450,23 @@ static void testReservedCellWidthGoesToTheCellsPresent() { layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0); } -// 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, 25, 26, -1}, {}}; - std::vector gs{g}; - const DeckLayout dl = layoutDeck(gs, 0, 0, 824); - const DeckGroupLayout& lay = dl.groups[0]; - CHECK(lay.cells.size() == 7); - - const int run = 8 * kDeckCellW; - 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); - - const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x; - CHECK(run - covered == expectedResidue); - const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX); - const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right(); - // Hard literals, not just the formula: this is the case that actually distinguishes - // symmetric (2/2) from trailing-only (0/4) — see the comment above. - CHECK(leadPad == 2); - CHECK(trailPad == 2); - CHECK(leadPad == expectedResidue / 2); - CHECK(trailPad == expectedResidue - leadPad); // both ends share it, not one absorbing it +// A face with NO reserve is untouched by the centring — the offset is zero by construction, so +// the run starts flush against the group's inner padding exactly as it always did. This is what +// makes "the Gate deck face is pixel-identical" a structural claim rather than an observation. +static void testAFaceWithNoReserveStartsFlushAgainstThePadding() { + for (int slots = 1; slots <= 8; ++slots) { + DeckGroupDesc g{0, 78, {}, {100, 88}, {}, {}, {}}; + for (int i = 0; i < slots; ++i) g.cellIds.push_back(20 + i); + std::vector gs{g}; + const DeckLayout dl = layoutDeck(gs, 0, 0, 824); + const DeckGroupLayout& lay = dl.groups[0]; + CHECK(static_cast(lay.cells.size()) == slots); + CHECK(lay.cells.front().cell.x == lay.box.x + kDeckGroupPadX); + // The run covers the whole reserve exactly — no lead margin to absorb, none to leave. + // (Not "flush right": a caption-row-bound group's box is wider than its knob row.) + CHECK(lay.cells.back().cell.right() - lay.cells.front().cell.x == slots * kDeckCellW); + for (const DeckCellLayout& c : lay.cells) CHECK(c.cell.width == kDeckCellW); + } } // The corner radio widens the caption row, takes the far corner, and pushes the caption @@ -449,7 +516,7 @@ static void testInnerDialHit() { // the caption text stops before the LEFTMOST one), and takes captionToggle's own slot when // captionToggle is absent — the shipped FILTER ENV group's exact shape (deck_groups.cpp). static void testCaptionToggle2() { - const DeckGroupDesc both{9, 40, {}, {300, 30}, {301, 20}, {1, 2, 3}, {}}; + const DeckGroupDesc both{9, 40, {}, {300, 60}, {301, 40}, {1, 2, 3}, {}}; std::vector g{both}; const DeckLayout dl = layoutDeck(g, 0, 0, 800); const DeckGroupLayout& lay = dl.groups[0]; @@ -469,7 +536,7 @@ static void testCaptionToggle2() { // FILTER ENV's real shape: captionToggle absent, captionToggle2 present with a radio — it // takes the first (rightmost) slot rather than leaving a gap where captionToggle would sit. - const DeckGroupDesc filterEnvLike{10, 66, {200}, {}, {302, 23}, {1, 2, 3, 4, 5}, {}}; + const DeckGroupDesc filterEnvLike{10, 66, {200}, {}, {302, 46}, {1, 2, 3, 4, 5}, {}}; std::vector g2{filterEnvLike}; const DeckLayout dl2 = layoutDeck(g2, 0, 0, 800); const DeckGroupLayout& fe = dl2.groups[0]; @@ -558,8 +625,10 @@ int main() { testSpanningOnlyDeckKeepsItsHeight(); testGroupInnerGeometry(); testHitTest(); - testReservedCellWidthGoesToTheCellsPresent(); - testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds(); + testSingleButtonToggleTakesTheWholeSlotAndCarriesNoSegment(); + testNoToggleRectOverlapsAKnobCircle(); + testAReserveCentresTheRunAndNeverWidensACell(); + testAFaceWithNoReserveStartsFlushAgainstThePadding(); testCaptionRadioGeometryAndHit(); testInnerDialHit(); testKnobFaceResolvesInnerRingOuterRingAndMisses(); diff --git a/tests/test_sample_chrome.cpp b/tests/test_sample_chrome.cpp index 050195c..a11d10e 100644 --- a/tests/test_sample_chrome.cpp +++ b/tests/test_sample_chrome.cpp @@ -54,15 +54,14 @@ static void testRowsTileTheBandExactly() { static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() { const Rect band = chromeBand(); const ChromeRects r = chromeRects(band, kKnob); - // Rightmost first: Browse, stereo, mono, velocity cell, preview, title. + // Rightmost first: Browse, channel, loop, velocity cell, preview, title. CHECK(r.navBrowse.right() == band.right() - kPad); CHECK(r.navBrowse.width == kNavButtonWidth); - CHECK(r.chanStereo.right() <= r.navBrowse.x); - CHECK(r.chanMono.right() == r.chanStereo.x); - CHECK(r.loopOn.right() <= r.chanMono.x); // the enable is immediately left of Mono|Stereo - CHECK(r.loopOff.right() == r.loopOn.x); // its two segments abut, like the channel pair - CHECK(r.loopOff.y == r.chanMono.y && r.loopOff.height == r.chanMono.height); - CHECK(r.velCell.right() <= r.loopOff.x); + CHECK(r.channel.right() <= r.navBrowse.x); + // Both are ONE button now, and they share the run's toggle baseline. + CHECK(r.loop.right() <= r.channel.x); + CHECK(r.loop.y == r.channel.y && r.loop.height == r.channel.height); + CHECK(r.velCell.right() <= r.loop.x); CHECK(r.preview.right() <= r.velCell.x); CHECK(r.bake.right() <= r.preview.x); CHECK(r.bake.width == kBakeButtonWidth); @@ -74,8 +73,8 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() { CHECK(r.title.width > 0); // Every toolbar rect sits inside the toolbar row. - const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, - r.loopOn, r.chanMono, r.chanStereo, r.navBrowse}; + const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loop, + r.channel, r.navBrowse}; for (const Rect& it : items) { CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom()); } @@ -88,8 +87,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() { // stay inside its own row, clear of every control. CHECK(!overlaps(r.toolbar, r.rootStrip)); CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom()); - const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, r.loopOn, - r.chanMono, r.chanStereo, r.navBrowse}; + const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loop, + r.channel, r.navBrowse}; for (const Rect& it : items) { CHECK(!overlaps(it, r.rootStrip)); CHECK(!overlaps(it, r.title)); @@ -176,7 +175,7 @@ static void testDegenerateBandYieldsNoInvertedRects() { kKnob); const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel, tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel, - tiny.loopOff, tiny.loopOn, tiny.chanMono, tiny.chanStereo, + tiny.loop, tiny.channel, tiny.navBrowse, tiny.rootStrip}; for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y); }