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.
This commit is contained in:
2026-07-22 21:55:16 -04:00
parent 322581227c
commit fc54472d3e
4 changed files with 789 additions and 8 deletions
+137
View File
@@ -2,8 +2,34 @@
#include "bank_grid.h"
#include <algorithm>
namespace reasampler {
namespace {
// Builds a sorted, unique ascending index vector for the inclusive range [a, b]
// (order-agnostic in a/b). Both ends assumed already in-range by the caller.
std::vector<int> rangeIndices(int a, int b) {
if (a > b) std::swap(a, b);
std::vector<int> out;
out.reserve(static_cast<std::size_t>(b - a + 1));
for (int i = a; i <= b; ++i) out.push_back(i);
return out;
}
// Clamps `index` to a valid cell (single-selection) result: sole member, focus and
// anchor both at index. Used by plain click and plain arrow.
Selection singleSelection(int index) {
Selection s;
s.indices = {index};
s.focus = index;
s.anchor = index;
return s;
}
} // namespace
int columnsForWidth(int panelWidth, const GridSpec& spec) {
// Layout: [gap][cell][gap][cell]...[cell][gap]. n cells occupy
// gap + n*(cellWidth + gap). Solve for the largest n that fits panelWidth,
@@ -62,4 +88,115 @@ std::string thumbnailKeyString(const ThumbnailKey& key) {
return s;
}
// --- Interaction --------------------------------------------------------------
int hitTestCell(int px, int py, const std::vector<CellRect>& rects) {
for (std::size_t i = 0; i < rects.size(); ++i) {
const CellRect& r = rects[i];
// Half-open bounds so adjacent (gapless) rects never both claim a pixel.
if (px >= r.x && px < r.x + r.width &&
py >= r.y && py < r.y + r.height)
return static_cast<int>(i);
}
return -1;
}
bool Selection::contains(int index) const {
return std::binary_search(indices.begin(), indices.end(), index);
}
Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
int itemCount) {
if (itemCount <= 0 || index < 0 || index >= itemCount) return current;
// Shift takes precedence over ctrl (documented): range-select from the anchor.
if (shift) {
const int anchor = current.anchor >= 0 && current.anchor < itemCount
? current.anchor
: index; // no valid anchor -> seed at the click
Selection s;
s.indices = rangeIndices(anchor, index);
s.focus = index;
s.anchor = anchor; // anchor unchanged across a shift-range
return s;
}
if (ctrl) {
Selection s = current;
auto it = std::lower_bound(s.indices.begin(), s.indices.end(), index);
if (it != s.indices.end() && *it == index)
s.indices.erase(it); // toggle OUT
else
s.indices.insert(it, index); // toggle IN (keeps sorted order)
s.focus = index;
s.anchor = index; // ctrl-click reseeds the range origin
return s;
}
// Plain click: sole selection.
return singleSelection(index);
}
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
bool shift) {
if (itemCount <= 0) return current;
if (cols < 1) cols = 1;
// A fresh panel (no focus): the first key press focuses cell 0 without moving,
// so the user sees the caret appear before it steps.
if (current.focus < 0 || current.focus >= itemCount) {
if (shift) {
Selection s;
s.indices = {0};
s.focus = 0;
s.anchor = 0;
return s;
}
return singleSelection(0);
}
const int from = current.focus;
int to = from;
switch (key) {
case NavKey::Left:
// Move one; clamp at cell 0 (stay put on the first cell).
if (from > 0) to = from - 1;
break;
case NavKey::Right:
// Move one; clamp at the last cell (stay put on the last cell).
if (from < itemCount - 1) to = from + 1;
break;
case NavKey::Up:
// Move up a row; if that leaves the grid (top row) stay put.
if (from - cols >= 0) to = from - cols;
break;
case NavKey::Down: {
// Move down a row. If the cell directly below exists, go there. If it
// does not (we're above a MISSING partial-last-row cell) but there ARE
// more cells, clamp to the last cell so the partial row is reachable.
// If we're already in the last populated row, stay put.
const int below = from + cols;
if (below < itemCount)
to = below;
else if (from + 1 < itemCount) // partial last row below us
to = itemCount - 1;
break;
}
case NavKey::Home: to = 0; break;
case NavKey::End: to = itemCount - 1; break;
}
if (!shift) return singleSelection(to);
// Shift-extend: keep the anchor (seed it at the origin cell on first extend).
const int anchor = current.anchor >= 0 && current.anchor < itemCount
? current.anchor
: from;
Selection s;
s.indices = rangeIndices(anchor, to);
s.focus = to;
s.anchor = anchor;
return s;
}
} // namespace reasampler