Merge Ω-W1-T4: deck reflow, focus-by-click overlay selection, single-button toggles

This commit is contained in:
2026-08-03 14:30:59 -04:00
23 changed files with 990 additions and 524 deletions
+10 -5
View File
@@ -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 (n1) 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 (n1) 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 90100 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.
+4
View File
@@ -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,
+44 -46
View File
@@ -12,11 +12,23 @@ namespace {
int id(DeckParam p) { return static_cast<int>(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<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
std::vector<DeckGroupDesc> 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<DeckGroupDesc> 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<DeckGroupDesc> 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<DeckGroupDesc> 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<DeckGroupDesc> 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<DeckGroupDesc> 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<DeckParam>(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<DeckParam>(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;
}
}
+10 -12
View File
@@ -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);
+11
View File
@@ -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
+7
View File
@@ -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
+49 -35
View File
@@ -24,7 +24,7 @@ int knobRowWidth(const DeckGroupDesc& g) {
int w = static_cast<int>(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<int>(g.cellIds.size()) * kDeckCellW;
int presentCells = 0;
for (int id : g.cellIds) {
if (id >= 0) ++presentCells;
}
const int cellW = presentCells > 0 ? runWidth / presentCells : 0;
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
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 {};
}
+26 -11
View File
@@ -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
+12 -18
View File
@@ -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,
+6 -7
View File
@@ -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
+10
View File
@@ -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;
+12 -25
View File
@@ -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<std::int64_t>(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);
+31 -22
View File
@@ -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<ParamControl>(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<ParamControl>(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<ParamControl>(dh.id)) != ParamControl::kCount) {
// Indexed by the OUTER cell id so the paint side can find the cell it belongs to.
+2 -5
View File
@@ -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
+43 -36
View File
@@ -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
+54 -30
View File
@@ -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;
}