// knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free // like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of // action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is // param_slider's — a knob cell here is just a rect the shell composes it into. Group/row // composition and the justification law are this directory's own CLAUDE.md's to describe. #pragma once #include #include "core/instrument/ui/editor_geometry.h" // Rect, contains — the shared geometry idiom namespace reasampler::instrument::ui { // Fixed deck metrics, exposed so the shell and tests agree. The cell/knob/label sizes were // raised together for legibility at high pixel densities. The deck's cell metrics AND its // group/row composition BOTH drive sample_bands' kEditorMinWidth; none of the three may move // alone. inline constexpr int kDeckCellW = 60; // one knob cell inline constexpr int kDeckCellH = 74; inline constexpr int kDeckKnobSize = 40; // knob diameter inside the cell inline constexpr int kDeckCellLabelH = 16; // the label band under the knob inline constexpr int kDeckCaptionH = 20; // the group caption row inline constexpr int kDeckToggleH = 18; // compact toggle segment height inline constexpr int kDeckGroupPadX = 6; // group box horizontal inner padding inline constexpr int kDeckGroupPadY = 4; // group box vertical inner padding inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap inline constexpr int kDeckGroupGap = 12; // gap between groups on a row inline constexpr int kDeckRowGap = 8; // gap between the deck's two categorical rows, // and between the spanning deck's stacked slots inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square inline constexpr int kDeckColumnGap = 8; // the spanning deck's cell column -> its readout column // The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while // the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is // deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob. inline constexpr int kDeckInnerDialSize = 20; // One group box: padding + caption + gap + cell row + padding. inline constexpr int kDeckGroupH = kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY; // The spanning deck's box: it stands across both categorical rows AND the seam between them, // which is what lets its two cell slots land on the two rows' own knob baselines. inline constexpr int kDeckSpanningH = 2 * kDeckGroupH + kDeckRowGap; // The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice // IS, Contour is how it moves over time, Spanning is what happens after the mixer. Declared // here rather than with the group inventory because the LAYOUT is what reads it; which group // sits in which row is deck_groups' deckRowFor. enum class DeckRow { Sound, Contour, Spanning }; // --- The deck's width budget at the editor's floor ------------------------------------ // DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a // group inventory that overruns one is what fails. sample_bands' kEditorMinWidth is derived // from the first two — kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2*kPad — and the // identity is asserted in test_deck_groups.cpp rather than coded, so the allocator keeps no // include edge to this header. // 1028 is the width at which the justification law puts BOTH rows' filter groups on the same // right edge (x = 640 block-relative) AND divides row 1's slack into three equal gutters. The // narrower 1020 delivered neither: 40 px over three gutters is 13⅓, so the law produced // 14/13/13 and left row 1's filter edge 2 px past row 2's. inline constexpr int kDeckRowBlockW = 1028; // the block both categorical rows justify inside inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck, outside the block // The hard ceiling the floor may not exceed lives beside the floor itself, in sample_bands.h's // kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 82px, // 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. struct DeckToggleDesc { int id = -1; // shell control id returned by the hit-test; -1 = no toggle int segWidth = 44; // px per segment }; // A single-square corner radio (an exclusive selector across groups, so the group itself // carries no state). id -1 = absent. `passive` reuses the same slot for a READOUT lamp: the // hit-test skips it entirely, so the shell cannot accidentally grow a gesture on it. struct DeckRadioDesc { int id = -1; bool passive = false; }; // A full-height readout column beside a spanning group's cell slots (the output meter). Only // a Spanning group may carry one — a categorical row's groups have no height to span. // id -1 = absent. struct DeckColumnDesc { int id = -1; int width = 0; }; // 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). // // 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. struct DeckGroupDesc { int id = 0; // shell group id (opaque here) int captionWidth = 60; DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none // A second caption toggle, placed immediately left of the first (or in its place when the // first is absent) — why this exists rather than a rowToggle is recorded once, at this // module's CLAUDE.md bullet. DeckToggleDesc captionToggle2; std::vector cellIds; // knob cells; -1 reserves width only, no cell (see above) DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none DeckRow row = DeckRow::Sound; DeckColumnDesc column; // Spanning groups only; id -1 = none }; // --- Laid-out geometry --------------------------------------------------------------- struct DeckToggleLayout { int id = -1; Rect seg0; // left segment Rect seg1; // right segment }; struct DeckRadioLayout { int id = -1; Rect box; bool passive = false; // a readout lamp, not a selector — see DeckRadioDesc }; struct DeckColumnLayout { int id = -1; Rect box; }; struct DeckCellLayout { int id = -1; Rect cell; // the whole cell; width is the group's reserved run divided by its cell count Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it) Rect inner; // the concentric kDeckInnerDialSize square inside `knob` Rect label; // the 12px label band beneath the knob }; struct DeckGroupLayout { int id = 0; Rect box; // the fenced group box Rect caption; // caption text rect (left part of the caption row) DeckRadioLayout captionRadio; // id -1 when absent (rect empty) DeckToggleLayout captionToggle; // id -1 when absent (rects empty) DeckToggleLayout captionToggle2; std::vector cells; DeckToggleLayout rowToggle; // id -1 when absent DeckColumnLayout column; // id -1 when absent (Spanning groups only) }; struct DeckLayout { std::vector groups; int rowCount = 0; // POPULATED categorical rows (0..2). A spanning deck is in neither. int height = 0; // the tallest thing laid out; 0 for no groups }; // Width of one group box: the wider of its caption row (caption + gap + toggles + radio) and // its knob row, plus horizontal padding. A Spanning group's knob row is one cell wide plus // its readout column, because its cells stack. int deckGroupWidth(const DeckGroupDesc& g); // How many of the two categorical rows carry at least one group (0..2). Independent of width: // row membership is the group's own property. int deckRowCount(const std::vector& groups); // Total deck height: the categorical rows, or the spanning deck when it is taller. The shell // bottom-anchors a band of exactly this height. int deckHeight(const std::vector& groups); // Lays the groups out from (left, top) within `availWidth`. Every rect is absolute, and // `groups` comes back in DECK order — the same position as the descriptor it was built from, // whichever row that descriptor landed in. // // Spanning groups are right-anchored at `left + availWidth` and take no part in either row's // justification; the ROW BLOCK is what remains to their left. Inside the block each row is // justified SPACE-BETWEEN: groups keep their natural widths and the slack becomes gutters, // divided equally with the integer residue going to the leftmost ones. Decks are never // stretched. Below the width the block needs, every gutter sits at kDeckGroupGap and the row // overflows right rather than wrapping — the shell clamps the window to a floor that fits // (sample_bands' kEditorMinWidth) via checkSizeConstraint, a host-honoured clamp rather than a // guarantee, so this degrade is defined and tested rather than assumed impossible. DeckLayout layoutDeck(const std::vector& groups, int left, int top, int availWidth); // --- Hit-test -------------------------------------------------------------------------- enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Column }; 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 bool inner = false; // Knob hits only: the grab landed on the cell's inner dial }; // The deck element a point lands on: a knob cell (the whole cell, not just the knob // circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking // a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, an // interactive caption-row corner radio, or a spanning group's readout column. Everything // else — fence, padding, a PASSIVE radio, outside — misses. DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); // The knob FACE a point lands on. id -1 is a miss. struct DeckFaceHit { int id = -1; bool inner = false; // inside the concentric inner disc }; // A point inside the circle inscribed in `knob` — radius is min(width, height)/2, same rule as // computeKnob's draw-side circle, so a non-square rect can never claim a hit past the drawn disc // — boundary-EXCLUSIVE. THE target rule for the reset gesture wherever a radial knob is drawn — // the deck's own faces and the chrome's preview-velocity dial both resolve through it, so one // gesture cannot grow two target rules. bool inKnobFace(const Rect& knob, int x, int y); // Resolved against the drawn CIRCLES, not the cell: a reset is aimed at a dial, so the label // band and the cell margins must miss where a drag grab deliberately does not. One rule for // both rings — a point exactly on the inner radius is an outer-ring hit, one exactly on the // outer radius is a miss. Whether a cell actually carries an inner value is deck_groups' call, // exactly as with DeckHit::inner. Unlike hitTestDeck this runs NO toggle/radio precedence pass // first, which is only correct while no toggle rect overlaps a knob circle — a layout change // that lets them overlap has to give this the same precedence order. DeckFaceHit hitTestKnobFace(const DeckLayout& layout, int x, int y); } // namespace reasampler::instrument::ui