227 lines
12 KiB
C++
227 lines
12 KiB
C++
// 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 <vector>
|
|
|
|
#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<int> 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<DeckCellLayout> cells;
|
|
DeckToggleLayout rowToggle; // id -1 when absent
|
|
DeckColumnLayout column; // id -1 when absent (Spanning groups only)
|
|
};
|
|
|
|
struct DeckLayout {
|
|
std::vector<DeckGroupLayout> 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<DeckGroupDesc>& 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<DeckGroupDesc>& 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<DeckGroupDesc>& 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
|