#pragma once // bank_grid — the REAPER-free layout math and cache-key logic behind the docked // bank_panel (M5, Wave A). The panel shell (bank_panel.cpp) owns the SWELL window, // LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What // is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and // the key that identifies a cached thumbnail — lives here so it is unit-tested // outside the DAW (CLAUDE.md §load-bearing split). // // PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library // only. Builds and unit-tests without REAPER. #include #include #include #include namespace reasampler { // A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE // convention). (x, y) is the top-left corner; width/height are the cell extents. // These are the draw bounds for one sample's thumbnail; the panel draws its // waveform envelope inside this rect (minus any internal padding it applies). struct CellRect { int x = 0; int y = 0; int width = 0; int height = 0; bool operator==(const CellRect& o) const { return x == o.x && y == o.y && width == o.width && height == o.height; } }; // Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the // TARGET cell size; the layout fits as many whole columns as the panel width // allows (>= 1) and wraps to as many rows as N requires. gap is the pixel spacing // between adjacent cells (and the outer margin), so cells never touch. struct GridSpec { int cellWidth = 120; int cellHeight = 72; int gap = 8; }; // Computes the number of columns that fit in a panel of the given pixel width for // the spec. Always >= 1 (a panel narrower than one cell still shows one column, // clipped by the window). Pure arithmetic — the panel passes its live client // width here and to computeCellRects. int columnsForWidth(int panelWidth, const GridSpec& spec); // Tiles `itemCount` cells left-to-right, top-to-bottom into a panel of the given // pixel width, honoring the spec's cell size and gap. Returns exactly itemCount // rects in item order (rect i is sample i). A partial last row is left-aligned // and simply shorter — no centering, no stretching. itemCount == 0 -> empty. // panelWidth is used only to derive the column count; the returned rects may // extend below any fixed viewport height (the panel scrolls/clips in Wave B). std::vector computeCellRects(int itemCount, int panelWidth, const GridSpec& spec); // The total pixel height the grid occupies for itemCount cells at the given panel // width and spec (top margin + rows*cellHeight + inter-row gaps + bottom margin). // 0 when itemCount == 0. The panel uses this to know its full content height // (scroll extent in Wave B; for Wave A it sizes the empty-vs-populated decision). int contentHeight(int itemCount, int panelWidth, const GridSpec& spec); // Identifies one cached thumbnail. A cached envelope is valid only while the // sample's identity, the draw width it was computed at, and the bank generation // it was computed under all match. Width is part of the key because the envelope // has exactly `width` bins per channel (peaks::computeEnvelope is width-driven); // a resized panel needs a fresh envelope. Generation lets the panel invalidate // every entry when the bank changes (capture / project load) without diffing. struct ThumbnailKey { std::string sampleId; int width = 0; std::uint64_t generation = 0; bool operator==(const ThumbnailKey& o) const { return sampleId == o.sampleId && width == o.width && generation == o.generation; } }; // A stable string form of the key, suitable as a map key. Deterministic: the same // key always yields the same string, distinct keys always differ (the sampleId is // length-prefixed so an id containing the delimiter cannot collide with another). std::string thumbnailKeyString(const ThumbnailKey& key); // --- Interaction (M5 Wave B): hit-test, selection, keyboard nav -------------- // // All REAPER-free so the panel's interaction LOGIC is unit-tested outside the DAW, // exactly as the layout math is. The panel shell (bank_panel.cpp) reads live mouse // coordinates / key codes / modifier state via SWELL and calls into these; it owns // no selection arithmetic of its own. // Hit-tests a point (SWELL/LICE top-left client coords) against a cell-rect list. // Returns the index of the FIRST rect that contains the point, or -1 for a miss // (a click in the inter-cell gap, the margin, or below the last row). Half-open // bounds [x, x+width) x [y, y+height) so adjacent rects never both claim a pixel. int hitTestCell(int px, int py, const std::vector& rects); // The panel's selection state. `indices` is the selected set as a SORTED, unique // ascending vector (deterministic for tests and for highlight iteration). `focus` // is the cell the caret sits on — the audition/extend target — or -1 when nothing // is focused. `anchor` is the fixed end of a shift-range (the cell a range extends // FROM); -1 when there is no active range origin. An empty selection has focus and // anchor both -1. // // Invariants (upheld by the pure mutators below, asserted in tests): // * indices is sorted ascending with no duplicates; // * every index (and focus/anchor when >= 0) is in [0, itemCount); // * focus, when >= 0, is a member of indices. struct Selection { std::vector indices; int focus = -1; int anchor = -1; bool operator==(const Selection& o) const { return indices == o.indices && focus == o.focus && anchor == o.anchor; } bool contains(int index) const; bool empty() const { return indices.empty(); } }; // Applies a mouse click on cell `index` to `current`, returning the new selection. // Modifier semantics (standard multi-select, matching file-manager conventions): // * plain (no modifier): select ONLY `index`; focus = anchor = index. // * ctrl: TOGGLE `index` in/out of the set; focus = index. Anchor moves to // index on add, and to index on remove too (a ctrl-click reseeds the // range origin at the clicked cell). If the toggle empties the set, // focus stays at index (the caret) but the set is empty. // * shift: select the inclusive RANGE from `anchor` to `index` (replacing the // set); focus = index, anchor unchanged. With no prior anchor (anchor // == -1) shift behaves like a plain click (anchor seeds at index). // `index` out of [0, itemCount) or itemCount <= 0 returns `current` unchanged. // ctrl and shift together: shift takes precedence (range select), matching common // UI; documented so the panel need not special-case it. Selection applyClick(const Selection& current, int index, bool ctrl, bool shift, int itemCount); // A directional key for keyboard navigation. REAPER-free (the shell maps VK_* to // these) so nav math is testable without SWELL. Enter/Space/Esc are NOT here: they // drive audition, which is a shell concern (no selection math), so the shell reads // those key codes directly. enum class NavKey { Left, Right, Up, Down, Home, End }; // Moves the focus by one step for `key` in a grid of `cols` columns holding // `itemCount` cells, returning the new selection. `cols` >= 1. // * Left/Right move by one cell in linear (row-major) order; Up/Down move by // `cols`. Movement CLAMPS at the grid ends (no wrap): Right on the last cell, // Left on the first, Up on the top row, Down past the last cell all stay put. // (Clamp, not wrap: wrap on a partial last row is surprising and error-prone; // clamp is the predictable choice — flagged as the deliberate decision.) // * Down from the second-to-last row into a column with no cell in the last row // clamps to the last cell rather than overshooting past itemCount. // * Without shift: the moved-to cell becomes the sole selection; focus = anchor // = newIndex (a plain arrow reseeds the range origin). // * With shift: focus moves to newIndex and the selection becomes the inclusive // range from anchor to newIndex (anchor unchanged); a first shift-arrow with no // anchor seeds the anchor at the ORIGIN cell before moving. // * Empty selection (focus == -1): the first arrow focuses cell 0 (Home-like), // so an arrow press on a fresh panel starts navigation predictably. // itemCount <= 0 returns `current` unchanged. Selection navigate(const Selection& current, NavKey key, int cols, int itemCount, bool shift); // --- Waveform display compression -------------------------------------------- // // Maps a raw linear amplitude magnitude to a perceptual display fraction so // quiet and medium content remains visible in the thumbnail. // // The floor below which amplitude is treated as silence (display fraction 0). // At -60 dB, 0.001 linear magnitude maps to ~0. Tune this constant in-DAW to // taste — it is the only knob for the compression curve. constexpr float kDisplayFloorDb = -60.0f; // Maps a signed linear amplitude value in [-1, 1] (a raw envelope extreme such // as PeakBin::max or PeakBin::min) to a signed display fraction in [-1, 1]. // // The magnitude |linear| is converted to dB, clamped to [kDisplayFloorDb, 0], // then normalized so kDisplayFloorDb -> 0 and 0 dB -> 1. The original sign is // re-applied so positive max values still map positive (draw up) and negative // min values still map negative (draw down). Exact-zero input returns 0.0f // (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f. float compressAmplitudeForDisplay(float linear); } // namespace reasampler