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
+216
View File
@@ -7,6 +7,11 @@
// itemCount == 0; a single item; a panel too narrow for even one cell (clamp to
// one column); content-height for exact and partial rows; cache-key stability,
// width/generation/id sensitivity, and length-prefix collision resistance.
//
// Wave B adds: hit-testing (inside / gap / out-of-range / half-open bounds);
// selection updates (plain / ctrl-toggle / shift-range, invariants preserved); and
// keyboard nav (arrow clamp, row moves, shift-extend, partial-last-row clamp,
// fresh-panel focus).
#include "../src/bank_grid.h"
@@ -128,6 +133,195 @@ static void testCacheKeyDelimiterCollisionResistance() {
CHECK(thumbnailKeyString(k1) != thumbnailKeyString(k2));
}
// --- Hit-testing --------------------------------------------------------------
// A 2x2 grid of the round spec: rects at (10,10),(120,10),(10,70),(120,70), each
// 100x50. Gaps sit at x in [110,120) and y in [60,70) and the outer margin < 10.
static std::vector<CellRect> grid2x2() {
return computeCellRects(4, 230, spec()); // 2 columns, 4 items
}
// A point inside a cell returns that cell's index; the top-left corner is inside
// (half-open lower bound), the bottom-right corner is NOT (half-open upper bound).
static void testHitTestInside() {
auto rects = grid2x2();
CHECK(hitTestCell(10, 10, rects) == 0); // top-left corner of cell 0: inside
CHECK(hitTestCell(60, 35, rects) == 0); // center of cell 0
CHECK(hitTestCell(120, 10, rects) == 1); // top-left of cell 1
CHECK(hitTestCell(10, 70, rects) == 2); // top-left of cell 2
CHECK(hitTestCell(120, 70, rects) == 3); // top-left of cell 3
// One pixel inside the far edge of cell 0 (x=109,y=59) still hits it.
CHECK(hitTestCell(109, 59, rects) == 0);
}
// The exclusive far edge (x+width, y+height) is a MISS — belongs to no cell, so
// adjacent gapless rects would never double-claim it.
static void testHitTestHalfOpenBounds() {
auto rects = grid2x2();
CHECK(hitTestCell(110, 35, rects) == -1); // x == cell0.x+width: past cell 0
CHECK(hitTestCell(60, 60, rects) == -1); // y == cell0.y+height: past cell 0
}
// A click in the inter-cell gap or the outer margin hits nothing.
static void testHitTestGapAndMargin() {
auto rects = grid2x2();
CHECK(hitTestCell(115, 35, rects) == -1); // horizontal gap between cols
CHECK(hitTestCell(60, 65, rects) == -1); // vertical gap between rows
CHECK(hitTestCell(0, 0, rects) == -1); // top-left margin
CHECK(hitTestCell(5, 35, rects) == -1); // left margin
}
// A click well outside the grid (below the last row / right of the last col) and
// an empty rect list both miss.
static void testHitTestOutOfRange() {
auto rects = grid2x2();
CHECK(hitTestCell(1000, 1000, rects) == -1);
CHECK(hitTestCell(60, 35, {}) == -1); // no cells at all
CHECK(hitTestCell(-5, -5, rects) == -1); // negative coords
}
// --- Selection updates --------------------------------------------------------
static bool selEq(const Selection& s, std::vector<int> idx, int focus, int anchor) {
return s.indices == idx && s.focus == focus && s.anchor == anchor;
}
// A plain click selects only that cell; focus and anchor both land on it,
// replacing any prior multi-selection.
static void testClickPlainReplaces() {
Selection start{{0, 1, 2}, 2, 0};
Selection s = applyClick(start, 4, /*ctrl=*/false, /*shift=*/false, 6);
CHECK(selEq(s, {4}, 4, 4));
}
// Ctrl-click adds an unselected cell (keeping the set sorted) and moves focus.
static void testClickCtrlAdds() {
Selection start{{1, 3}, 3, 1};
Selection s = applyClick(start, 2, /*ctrl=*/true, /*shift=*/false, 6);
CHECK(selEq(s, {1, 2, 3}, 2, 2)); // inserted in sorted position
}
// Ctrl-click on an already-selected cell removes it (toggle out); focus still
// moves to the clicked cell even though it left the set.
static void testClickCtrlRemoves() {
Selection start{{1, 2, 3}, 3, 1};
Selection s = applyClick(start, 2, /*ctrl=*/true, /*shift=*/false, 6);
CHECK(selEq(s, {1, 3}, 2, 2));
}
// Shift-click selects the inclusive range from the existing anchor to the click,
// leaving the anchor put; order-agnostic (anchor above or below the click).
static void testClickShiftRange() {
Selection start{{2}, 2, 2}; // anchor at 2
Selection s = applyClick(start, 5, /*ctrl=*/false, /*shift=*/true, 8);
CHECK(selEq(s, {2, 3, 4, 5}, 5, 2));
// Downward range (click above the anchor) yields the same inclusive set.
Selection s2 = applyClick(start, 0, false, true, 8);
CHECK(selEq(s2, {0, 1, 2}, 0, 2));
}
// Shift-click with no prior anchor behaves like a plain click (anchor seeds at the
// clicked cell).
static void testClickShiftNoAnchor() {
Selection start{}; // focus/anchor == -1
Selection s = applyClick(start, 3, false, true, 6);
CHECK(selEq(s, {3}, 3, 3));
}
// Shift takes precedence over ctrl when both are held (range select, documented).
static void testClickShiftBeatsCtrl() {
Selection start{{1}, 1, 1};
Selection s = applyClick(start, 3, /*ctrl=*/true, /*shift=*/true, 6);
CHECK(selEq(s, {1, 2, 3}, 3, 1)); // range, not toggle
}
// An out-of-range index (or empty grid) returns the selection unchanged.
static void testClickOutOfRangeNoop() {
Selection start{{1, 2}, 2, 1};
CHECK(applyClick(start, 9, false, false, 6) == start);
CHECK(applyClick(start, -1, false, false, 6) == start);
CHECK(applyClick(start, 0, false, false, 0) == start);
}
// --- Keyboard navigation ------------------------------------------------------
// Right/Left move by one in linear order; Down/Up move by a row (cols cells).
static void testNavArrowsMoveOneAndRow() {
// 6 items, 3 columns: rows [0,1,2],[3,4,5]. Focus at 1.
Selection start{{1}, 1, 1};
CHECK(selEq(navigate(start, NavKey::Right, 3, 6, false), {2}, 2, 2));
CHECK(selEq(navigate(start, NavKey::Left, 3, 6, false), {0}, 0, 0));
CHECK(selEq(navigate(start, NavKey::Down, 3, 6, false), {4}, 4, 4));
// Up from row 1 back to row 0.
Selection row1{{4}, 4, 4};
CHECK(selEq(navigate(row1, NavKey::Up, 3, 6, false), {1}, 1, 1));
}
// Movement clamps at every edge (no wrap): Left on cell 0, Right on the last cell,
// Up on the top row, Down past the last cell all stay put.
static void testNavClampsAtEdges() {
CHECK(selEq(navigate(Selection{{0}, 0, 0}, NavKey::Left, 3, 6, false), {0}, 0, 0));
CHECK(selEq(navigate(Selection{{5}, 5, 5}, NavKey::Right, 3, 6, false), {5}, 5, 5));
CHECK(selEq(navigate(Selection{{2}, 2, 2}, NavKey::Up, 3, 6, false), {2}, 2, 2));
CHECK(selEq(navigate(Selection{{5}, 5, 5}, NavKey::Down, 3, 6, false), {5}, 5, 5));
}
// Down from a cell above a MISSING last-row cell clamps to the last cell rather
// than overshooting past itemCount. 5 items, 3 cols: rows [0,1,2],[3,4]. Down from
// 2 would be 5 (absent) -> clamps to 4.
static void testNavDownPartialLastRowClamps() {
Selection start{{2}, 2, 2};
CHECK(selEq(navigate(start, NavKey::Down, 3, 5, false), {4}, 4, 4));
}
// Home/End jump to the first/last cell.
static void testNavHomeEnd() {
Selection start{{3}, 3, 3};
CHECK(selEq(navigate(start, NavKey::Home, 3, 6, false), {0}, 0, 0));
CHECK(selEq(navigate(start, NavKey::End, 3, 6, false), {5}, 5, 5));
}
// Shift+arrow extends the range from the anchor; the anchor stays put as focus
// walks. Repeated shift-right grows the set.
static void testNavShiftExtends() {
Selection start{{1}, 1, 1}; // anchor at 1
Selection s1 = navigate(start, NavKey::Right, 3, 6, true);
CHECK(selEq(s1, {1, 2}, 2, 1));
Selection s2 = navigate(s1, NavKey::Right, 3, 6, true);
CHECK(selEq(s2, {1, 2, 3}, 3, 1));
// Shift-down from the grown range extends by a whole row from the anchor.
Selection s3 = navigate(s1, NavKey::Down, 3, 6, true); // focus 2 -> 5
CHECK(selEq(s3, {1, 2, 3, 4, 5}, 5, 1));
}
// Shift-extending back toward the anchor shrinks the range (focus crosses the
// anchor without moving it).
static void testNavShiftShrinksAndCrosses() {
Selection start{{1, 2, 3}, 3, 1}; // anchor 1, focus 3
Selection s = navigate(start, NavKey::Left, 3, 6, true); // focus 3 -> 2
CHECK(selEq(s, {1, 2}, 2, 1));
Selection s2 = navigate(s, NavKey::Left, 3, 6, true); // focus 2 -> 1 (anchor)
CHECK(selEq(s2, {1}, 1, 1));
Selection s3 = navigate(s2, NavKey::Left, 3, 6, true); // cross below anchor
CHECK(selEq(s3, {0, 1}, 0, 1));
}
// A fresh panel (empty selection) focuses cell 0 on the first arrow without
// stepping, with and without shift.
static void testNavFromEmptyFocusesFirst() {
Selection empty{};
CHECK(selEq(navigate(empty, NavKey::Down, 3, 6, false), {0}, 0, 0));
CHECK(selEq(navigate(empty, NavKey::Right, 3, 6, true), {0}, 0, 0));
}
// itemCount <= 0 returns the selection unchanged; cols < 1 is treated as 1.
static void testNavDegenerate() {
Selection start{{1}, 1, 1};
CHECK(navigate(start, NavKey::Right, 3, 0, false) == start);
// cols coerced to 1: Down moves by 1 in a single-column grid.
CHECK(selEq(navigate(Selection{{0}, 0, 0}, NavKey::Down, 0, 4, false), {1}, 1, 1));
}
int main() {
testColumnsForWidth();
testTooNarrowClampsToOneColumn();
@@ -139,6 +333,28 @@ int main() {
testCacheKeyStabilityAndSensitivity();
testCacheKeyDelimiterCollisionResistance();
testHitTestInside();
testHitTestHalfOpenBounds();
testHitTestGapAndMargin();
testHitTestOutOfRange();
testClickPlainReplaces();
testClickCtrlAdds();
testClickCtrlRemoves();
testClickShiftRange();
testClickShiftNoAnchor();
testClickShiftBeatsCtrl();
testClickOutOfRangeNoop();
testNavArrowsMoveOneAndRow();
testNavClampsAtEdges();
testNavDownPartialLastRowClamps();
testNavHomeEnd();
testNavShiftExtends();
testNavShiftShrinksAndCrosses();
testNavFromEmptyFocusesFirst();
testNavDegenerate();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}