// knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes. #include "core/instrument/ui/knob_deck.h" #include namespace reasampler::instrument::ui { namespace { // The knob-row width of a group: cells side by side (no inter-cell gap — the 60px cell // already carries its own breathing room around the 40px knob), plus the optional row // toggle after a kDeckToggleGap. A spanning group's cells stack, so its knob row is one // cell wide plus whatever readout column sits beside it. int knobRowWidth(const DeckGroupDesc& g) { if (g.row == DeckRow::Spanning) { int w = g.cellIds.empty() ? 0 : kDeckCellW; if (g.column.id >= 0) { if (w > 0) w += kDeckColumnGap; w += g.column.width; } return w; } int w = static_cast(g.cellIds.size()) * kDeckCellW; if (g.rowToggle.id >= 0) { if (w > 0) w += kDeckToggleGap; w += 2 * g.rowToggle.segWidth; } return w; } // 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.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize; return w; } // 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) { DeckCellLayout c; c.id = id; c.cell = cell; const int knobLeft = cell.x + (cell.width - kDeckKnobSize) / 2; const int knobTop = cell.y + 4; c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize); const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2; const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2; c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize, innerTopPx + kDeckInnerDialSize); const int labelTop = knobTop + kDeckKnobSize + 4; c.label = Rect::ltrb(cell.x, labelTop, cell.right(), labelTop + kDeckCellLabelH); return c; } // Place one group's inner geometry given its box. DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { DeckGroupLayout out; out.id = g.id; out.box = box; const int captionTop = box.y + kDeckGroupPadY; const int innerLeft = box.x + kDeckGroupPadX; const int innerRight = box.right() - kDeckGroupPadX; // Caption row: text left, then the compact toggle, then the corner radio at the far edge. out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH); int captionRight = innerRight; if (g.captionRadio.id >= 0) { const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2; out.captionRadio = DeckRadioLayout{ g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight, radioTop + kDeckRadioSize), g.captionRadio.passive}; captionRight = out.captionRadio.box.x - kDeckToggleGap; out.caption.width = captionRight - out.caption.x; } 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; // Caption text stops at the leftmost toggle: pull the right edge in (XYWH: width). out.caption.width = captionRight - out.caption.x; }; placeToggle(g.captionToggle, out.captionToggle); placeToggle(g.captionToggle2, out.captionToggle2); const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap; if (g.row == DeckRow::Spanning) { // FIXED slots down the left column, one per declared id (reserves advance the slot // without drawing a cell), spaced by a whole row pitch so slot k lands exactly on // categorical row k's knob baseline. Deliberately NOT the run-division law below. int slotTop = cellTop; for (int id : g.cellIds) { if (id >= 0) { out.cells.push_back(layoutCell( id, Rect::ltrb(innerLeft, slotTop, innerLeft + kDeckCellW, slotTop + kDeckCellH))); } slotTop += kDeckGroupH + kDeckRowGap; } if (g.column.id >= 0) { // ONE rect spanning every slot, not a readout per row. Right-anchored off // innerRight rather than measured past the cell slot, so a wider caption // reserve on this group can never detach the column from the padding. const int colX = innerRight - g.column.width; out.column = DeckColumnLayout{ g.column.id, Rect::ltrb(colX, cellTop, colX + g.column.width, box.bottom() - kDeckGroupPadY)}; } 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. 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; for (int id : g.cellIds) { if (id < 0) continue; out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH))); x += cellW; } if (g.rowToggle.id >= 0) { // Anchored past the whole reserved run, not past the last cell, so a residue margin // cannot shift it. int tx = innerLeft + runWidth; if (!g.cellIds.empty()) tx += kDeckToggleGap; const int segW = g.rowToggle.segWidth; const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2; const Rect seg0 = Rect::ltrb(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}; } return out; } // The gutters between `count` groups whose widths total `total`, justified space-between // inside `blockW`. Empty for a single group. std::vector justifyGutters(int count, int total, int blockW) { const int gutters = count - 1; if (gutters <= 0) return {}; const int slack = blockW - total; if (slack < gutters * kDeckGroupGap) { // The block cannot hold the row: minimum gutters, and the row overruns to the right // rather than wrapping — see layoutDeck's header note for when this degrade applies. return std::vector(static_cast(gutters), kDeckGroupGap); } const int base = slack / gutters; const int residue = slack % gutters; // both non-negative: slack >= gutters * 12 > 0 std::vector out(static_cast(gutters), base); for (int i = 0; i < residue; ++i) ++out[static_cast(i)]; return out; } } // namespace int deckGroupWidth(const DeckGroupDesc& g) { return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX; } int deckRowCount(const std::vector& groups) { bool sound = false, contour = false; for (const DeckGroupDesc& g : groups) { if (g.row == DeckRow::Sound) sound = true; else if (g.row == DeckRow::Contour) contour = true; } return (sound ? 1 : 0) + (contour ? 1 : 0); } int deckHeight(const std::vector& groups) { const int rows = deckRowCount(groups); int h = rows > 0 ? rows * kDeckGroupH + (rows - 1) * kDeckRowGap : 0; for (const DeckGroupDesc& g : groups) { if (g.row == DeckRow::Spanning) h = (std::max)(h, kDeckSpanningH); } return h; } DeckLayout layoutDeck(const std::vector& groups, int left, int top, int availWidth) { DeckLayout out; if (groups.empty()) return out; // Partition by the group's OWN row (indices, so the OUTPUT keeps deck order — the shell // and the tests pair a layout with the descriptor at the same position). std::vector rows[2]; std::vector spanning; for (std::size_t i = 0; i < groups.size(); ++i) { const DeckRow row = groups[i].row; if (row == DeckRow::Spanning) spanning.push_back(i); else rows[row == DeckRow::Contour ? 1 : 0].push_back(i); } // The spanning decks take the right edge; the row block is what is left of them. int spanTotal = 0; for (std::size_t i : spanning) spanTotal += deckGroupWidth(groups[i]); if (!spanning.empty()) { spanTotal += (static_cast(spanning.size()) - 1) * kDeckGroupGap; } const int blockW = availWidth - (spanning.empty() ? 0 : spanTotal + kDeckGroupGap); std::vector boxes(groups.size()); int y = top; for (const std::vector& row : rows) { if (row.empty()) continue; // an absent category collapses; it leaves no empty band ++out.rowCount; int total = 0; for (std::size_t i : row) total += deckGroupWidth(groups[i]); const std::vector gutters = justifyGutters(static_cast(row.size()), total, blockW); int x = left; for (std::size_t k = 0; k < row.size(); ++k) { const int w = deckGroupWidth(groups[row[k]]); boxes[row[k]] = Rect::ltrb(x, y, x + w, y + kDeckGroupH); x += w; if (k < gutters.size()) x += gutters[k]; } y += kDeckGroupH + kDeckRowGap; } int sx = left + availWidth - spanTotal; for (std::size_t i : spanning) { const int w = deckGroupWidth(groups[i]); boxes[i] = Rect::ltrb(sx, top, sx + w, top + kDeckSpanningH); sx += w + kDeckGroupGap; } out.groups.reserve(groups.size()); for (std::size_t i = 0; i < groups.size(); ++i) { out.groups.push_back(layoutGroup(groups[i], boxes[i])); } out.height = deckHeight(groups); return out; } DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { for (const DeckGroupLayout& g : layout.groups) { 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}; } 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}; } 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}; } 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)}; } } if (g.column.id >= 0 && contains(g.column.box, x, y)) { return {DeckHitKind::Column, g.column.id, -1, false}; } // Inside the box but on fence/padding — a miss. 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 {}; } bool inKnobFace(const Rect& knob, int x, int y) { const double dx = x - (knob.x + knob.width / 2.0); const double dy = y - (knob.y + knob.height / 2.0); // Matches computeKnob's radius rule (param_slider.cpp) so the hit rule never claims more // circle than is actually drawn when a caller's rect is non-square (e.g. a squashed chrome row). const double r = (std::min)(knob.width, knob.height) / 2.0; return dx * dx + dy * dy < r * r; } DeckFaceHit hitTestKnobFace(const DeckLayout& layout, int x, int y) { for (const DeckGroupLayout& g : layout.groups) { if (!contains(g.box, x, y)) continue; for (const DeckCellLayout& c : g.cells) { if (!inKnobFace(c.knob, x, y)) continue; return {c.id, inKnobFace(c.inner, x, y)}; } return {}; // inside the group but off every dial } return {}; } } // namespace reasampler::instrument::ui