47f2a063e7
Aligns inKnobFace's hit radius with computeKnob's draw-side min(w,h) rule and adds a non-square-rect test; clamps halfSpan for degenerate waveform bands with a test; fixes stale docs/comments and annotates an uncommitted perf measurement.
215 lines
8.9 KiB
C++
215 lines
8.9 KiB
C++
// knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes.
|
|
|
|
#include "core/instrument/ui/knob_deck.h"
|
|
|
|
#include <algorithm>
|
|
|
|
namespace reasampler::instrument::ui {
|
|
|
|
namespace {
|
|
|
|
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
|
|
// already carries its own breathing room around the 28px knob), plus the optional row
|
|
// toggle after a kDeckToggleGap.
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
|
|
// 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)};
|
|
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);
|
|
|
|
// 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 cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
|
|
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;
|
|
for (int id : g.cellIds) {
|
|
if (id < 0) continue;
|
|
DeckCellLayout c;
|
|
c.id = id;
|
|
c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
|
|
const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
|
|
const int knobTop = cellTop + 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(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
|
|
out.cells.push_back(c);
|
|
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;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int deckGroupWidth(const DeckGroupDesc& g) {
|
|
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
|
|
}
|
|
|
|
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth) {
|
|
if (groups.empty()) return 0;
|
|
int rows = 1;
|
|
int x = 0;
|
|
for (const DeckGroupDesc& g : groups) {
|
|
const int w = deckGroupWidth(g);
|
|
if (x > 0 && x + kDeckGroupGap + w > availWidth) {
|
|
++rows;
|
|
x = w;
|
|
} else {
|
|
x += (x > 0 ? kDeckGroupGap : 0) + w;
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth) {
|
|
const int rows = deckRowCount(groups, availWidth);
|
|
if (rows == 0) return 0;
|
|
return rows * kDeckGroupH + (rows - 1) * kDeckRowGap;
|
|
}
|
|
|
|
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
|
|
int availWidth) {
|
|
DeckLayout out;
|
|
if (groups.empty()) return out;
|
|
int x = left;
|
|
int y = top;
|
|
bool rowHasGroup = false;
|
|
out.rowCount = 1;
|
|
for (const DeckGroupDesc& g : groups) {
|
|
const int w = deckGroupWidth(g);
|
|
if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) {
|
|
// Wrap: whole trailing group onto the next row (mirror of deckRowCount).
|
|
++out.rowCount;
|
|
x = left;
|
|
y += kDeckGroupH + kDeckRowGap;
|
|
rowHasGroup = false;
|
|
}
|
|
if (rowHasGroup) x += kDeckGroupGap;
|
|
const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
|
|
out.groups.push_back(layoutGroup(g, box));
|
|
x = box.right();
|
|
rowHasGroup = true;
|
|
}
|
|
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
|
|
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 && 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)};
|
|
}
|
|
}
|
|
return {}; // inside the box but on fence/padding — a miss (groups never overlap)
|
|
}
|
|
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
|