// 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. // // The deck is a horizontal run of fenced groups, left->right, each a bordered box with a // caption row (caption left, the group's compact mode toggle right-anchored) over a knob // row of fixed cells (knob centered, label band beneath). A group may also place one // two-segment toggle in the knob row after its cells. Groups that must keep stable // geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows // neighbouring groups. // // Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group // that does not fit the remaining width starts a new row (whole groups only, never // split); the first group of a row always places even if wider than the row. #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. inline constexpr int kDeckCellW = 48; // one knob cell inline constexpr int kDeckCellH = 58; inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell inline constexpr int kDeckCellLabelH = 12; // the Micro 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 wrapped deck rows inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square // 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 = 14; // One group box: padding + caption + gap + cell row + padding. inline constexpr int kDeckGroupH = kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY; // 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. struct DeckRadioDesc { int id = -1; }; // One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 // is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell // reserves for the caption text (this module does not measure text). 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 = blank reserve DeckToggleDesc rowToggle; // in the knob row after the cells; 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; }; struct DeckCellLayout { int id = -1; Rect cell; // the full 48x58 cell 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 }; struct DeckLayout { std::vector groups; int rowCount = 0; int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups }; // Width of one group box: the wider of its caption row (caption + gap + toggle) and its // knob row (cells + gap + row toggle), plus horizontal padding. int deckGroupWidth(const DeckGroupDesc& g); // Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap. // 0 for an empty list. int deckRowCount(const std::vector& groups, int availWidth); // Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell // bottom-anchors a band of exactly this height. int deckHeight(const std::vector& groups, int availWidth); // Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's // rule. Every rect is absolute. DeckLayout layoutDeck(const std::vector& groups, int left, int top, int availWidth); // --- Hit-test -------------------------------------------------------------------------- enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio }; 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, or // the caption-row corner radio. Blank cells (id -1) and everything else miss. DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); } // namespace reasampler::instrument::ui