Files
reasampler/src/bank_grid.h
T
daniel fc54472d3e feat(bank_panel): audition, multi-select, and keyboard nav (M5 Wave B)
Pure bank_grid gains hit-test, selection-update, and arrow-nav math with
tests; the panel wires mouse multi-select, keyboard nav via an accelerator
hook, and stock PlayPreview/StopPreview audition with a leak-free preview
lifecycle. Read-only: no arrange insertion, no project/bank mutation.
2026-07-22 21:55:16 -04:00

167 lines
8.6 KiB
C++

#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 <cstddef>
#include <cstdint>
#include <string>
#include <vector>
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<CellRect> 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<CellRect>& 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<int> 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);
} // namespace reasampler