From fc54472d3e041288117812c2c5c9b2a471d48da9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 22 Jul 2026 21:55:16 -0400 Subject: [PATCH] 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. --- src/bank_grid.cpp | 137 +++++++++++++++ src/bank_grid.h | 78 +++++++++ src/bank_panel.cpp | 366 ++++++++++++++++++++++++++++++++++++++- tests/test_bank_grid.cpp | 216 +++++++++++++++++++++++ 4 files changed, 789 insertions(+), 8 deletions(-) diff --git a/src/bank_grid.cpp b/src/bank_grid.cpp index 783128b..c393d1f 100644 --- a/src/bank_grid.cpp +++ b/src/bank_grid.cpp @@ -2,8 +2,34 @@ #include "bank_grid.h" +#include + 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 rangeIndices(int a, int b) { + if (a > b) std::swap(a, b); + std::vector out; + out.reserve(static_cast(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& 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(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 diff --git a/src/bank_grid.h b/src/bank_grid.h index ddb7612..a0bf2a8 100644 --- a/src/bank_grid.h +++ b/src/bank_grid.h @@ -85,4 +85,82 @@ struct ThumbnailKey { // 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); + } // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 47846c7..d5a6c86 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -48,6 +48,9 @@ // WDL_DLGRET (the platform dialog-proc return type). #ifdef _WIN32 #include +#include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) +#else +#include #endif #include "wdltypes.h" #include "swell/swell.h" @@ -55,6 +58,11 @@ #include "resource.h" +// reaper_plugin.h defines preview_register_t (the stock preview struct) and the +// REAPER_PLUGIN_HINSTANCE / registration types. main.cpp includes it with +// REAPERAPI_IMPLEMENT; here we only need the type declarations. +#include "reaper_plugin.h" + #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_DockWindowAddEx #define REAPERAPI_WANT_DockWindowActivate @@ -63,10 +71,17 @@ #define REAPERAPI_WANT_GetMainHwnd #define REAPERAPI_WANT_PCM_Source_CreateFromFile #define REAPERAPI_WANT_PCM_Source_Destroy +// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h): +// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the +// STOCK symbols (not SWS-only) — see the audition section below. +#define REAPERAPI_WANT_PlayPreview +#define REAPERAPI_WANT_StopPreview #include "reaper_plugin_functions.h" -// main.cpp owns the module instance handle (needed to load the dialog resource). +// main.cpp owns the module instance handle (needed to load the dialog resource) +// and REAPER's dispatch struct (needed to register the keyboard accelerator hook). extern REAPER_PLUGIN_HINSTANCE g_hInst; +extern reaper_plugin_info_t* g_rec; namespace reasampler { @@ -94,6 +109,12 @@ const LICE_pixel kColCellBorder = LICE_RGBA(70, 70, 76, 255); const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255); const LICE_pixel kColMidline = LICE_RGBA(60, 60, 66, 255); const LICE_pixel kColText = LICE_RGBA(200, 200, 205, 255); +// Selection chrome (Wave B). Selected cells get a tinted fill + brighter border; +// the focused cell (audition/nav target) gets a distinct accent border so it is +// distinguishable within a multi-selection. +const LICE_pixel kColSelBg = LICE_RGBA(38, 66, 58, 255); // selected fill tint +const LICE_pixel kColSelBorder = LICE_RGBA(120, 200, 160, 255);// selected border +const LICE_pixel kColFocusBorder = LICE_RGBA(210, 230, 220, 255);// focused-cell border // --- Panel state -------------------------------------------------------------- @@ -122,10 +143,39 @@ struct PanelState { // Entries for stale generations are lazily overwritten on next miss; a bank // change also clears it wholesale (see refreshFingerprint) to bound memory. std::unordered_map cache; + + // --- Interaction (Wave B) ------------------------------------------------- + + // The current cell selection (indices into bank->all(), focus, anchor). Pure + // math lives in bank_grid; this holds the live state the pointer/keyboard + // mutate. A bank change (generation bump) resets it (indices could dangle). + Selection selection; + + // The item count the selection was last validated against. On a bank change we + // clear the selection rather than risk indices pointing past the new count. + int selItemCount = 0; + + // --- Audition preview (Wave B) -------------------------------------------- + // + // The stock preview register we hand to PlayPreview/StopPreview. Its cs/mutex + // is initialized ONCE (initPreview) and destroyed ONCE (deinitPreview) across + // the panel's lifetime — NOT per playback — because REAPER's audio thread may + // touch the register's guarded fields. `previewSrc` is the PCM_source currently + // owned by `preview.src`; non-null exactly while auditioning. `previewActive` + // tracks whether PlayPreview succeeded and StopPreview is still owed. + preview_register_t preview{}; + PCM_source* previewSrc = nullptr; + bool previewActive = false; + bool previewInited = false; // guards double init / deinit }; PanelState g_panel; +// Forward declarations for the interaction/audition helpers defined lower down but +// referenced by earlier sections (e.g. refreshFingerprint stops audition on a bank +// change). Definitions live in the "Audition preview" / "Selection + input" blocks. +void stopAudition(); + // --- Current-project directory (mirrors persist.cpp's derivation) ------------- // // The index stores relative paths; resolving a bank file needs the current .rpp @@ -228,9 +278,21 @@ const Envelope& thumbnailFor(const Sample& sample, int width, // zero midline, and the min/max waveform. Multi-channel envelopes are stacked // vertically (each channel gets an equal horizontal band) so a stereo sample shows // both channels without folding (precision invariant: no stereo fold). -void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env) { - LICE_FillRect(bmp, rect.x, rect.y, rect.width, rect.height, kColCellBg, 1.0f, 0); - LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, kColCellBorder, 1.0f, 0); +// `selected` tints the fill and brightens the border; `focused` overrides the +// border with the accent color so the caret cell reads within a multi-selection. +void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, + bool selected, bool focused) { + const LICE_pixel bg = selected ? kColSelBg : kColCellBg; + LICE_pixel border = selected ? kColSelBorder : kColCellBorder; + if (focused) border = kColFocusBorder; + + LICE_FillRect(bmp, rect.x, rect.y, rect.width, rect.height, bg, 1.0f, 0); + LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, border, 1.0f, 0); + // The focused cell gets a second inset rectangle so it stays distinct even when + // its neighbors are also selected (double outline reads as "the active one"). + if (focused) + LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, + border, 1.0f, 0); if (env.empty()) { // Unreadable / empty sample: cell drawn, no waveform. A single midline @@ -288,6 +350,21 @@ void drawEmptyState(HWND hwnd, LICE_IBitmap* bmp, int w, int h) { DrawText(dc, msg, -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_WORDBREAK); } +// The cell rects for the panel's CURRENT client width and bank size. Both paint +// and mouse hit-testing call this so they share identical geometry (no drift +// between what is drawn and what a click resolves to). Returns empty when the +// window is gone or the bank is empty. +std::vector panelRects() { + if (!g_panel.hwnd) return {}; + const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr; + if (!bank || bank->empty()) return {}; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left; + if (w <= 0) return {}; + return computeCellRects(static_cast(bank->size()), w, kGrid); +} + // The full paint: build/refresh the LICE backing bitmap at client size, draw the // grid (or empty state), then blit to the window HDC. void paintPanel(HWND hwnd, HDC hdc) { @@ -318,8 +395,11 @@ void paintPanel(HWND hwnd, HDC hdc) { // Skip cells entirely below the viewport (Wave A has no scroll; this // just avoids computing thumbnails that cannot be seen). if (rect.y >= h) continue; + const int idx = static_cast(i); + const bool selected = g_panel.selection.contains(idx); + const bool focused = g_panel.selection.focus == idx; const Envelope& env = thumbnailFor(samples[i], binWidth, projectDir); - drawThumbnail(&bmp, rect, env); + drawThumbnail(&bmp, rect, env, selected, focused); } } @@ -352,9 +432,252 @@ bool refreshFingerprint() { g_panel.bankFingerprint = std::move(fp); ++g_panel.generation; g_panel.cache.clear(); + // The selection indexes into the OLD bank order; a bank change (capture / + // project load) can invalidate those indices, so clear it and stop any + // audition of a sample that may no longer exist at the same index. + if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { + g_panel.selection = Selection{}; + stopAudition(); + } + g_panel.selItemCount = static_cast(g_panel.session->bank().size()); return true; } +// --- Audition preview --------------------------------------------------------- +// +// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW +// playback only. It NEVER inserts into the arrange, creates items/tracks, or +// mutates the project or bank. PlayPreview streams a caller-owned PCM_source +// through REAPER's preview bus and touches nothing in the project. +// +// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by +// signature/struct, not semantics — DAW-verify): +// 1. REAPER's audio thread reads the preview_register_t by POINTER while the +// preview is active (the struct's own comment mandates a cs/mutex we init), +// so the register must outlive playback — we hold it in g_panel (static), +// never on the stack. +// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it +// returns, making it safe to PCM_Source_Destroy the source immediately after. +// This is the conventional contract (SWS' preview helpers rely on it) but is +// NOT documented in the header — flagged. If a rare race surfaced, the fix is +// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence). +// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not +// set mono (&1024). volume 1.0, loop false, curpos 0. + +// Initializes the preview register's cs/mutex ONCE for the panel's lifetime. The +// preview struct guards its fields with a platform lock the caller must set up +// (reaper_plugin.h). Idempotent. +void initPreview() { + if (g_panel.previewInited) return; +#ifdef _WIN32 + InitializeCriticalSection(&g_panel.preview.cs); +#else + pthread_mutex_init(&g_panel.preview.mutex, nullptr); +#endif + g_panel.previewInited = true; +} + +// Stops any active preview and frees the owned PCM_source. Safe to call when +// nothing is playing (no-op). Every stop path funnels through here so the source +// is freed exactly once and never dangles. +void stopAudition() { + if (g_panel.previewActive) { + StopPreview(&g_panel.preview); + g_panel.previewActive = false; + } + // Free the source AFTER StopPreview has detached it (assumption #2). Clear the + // register's src so a stale pointer can never be handed back to PlayPreview. + if (g_panel.previewSrc) { + PCM_Source_Destroy(g_panel.previewSrc); + g_panel.previewSrc = nullptr; + } + g_panel.preview.src = nullptr; +} + +// Destroys the preview register's cs/mutex on panel teardown, after stopAudition. +void deinitPreview() { + if (!g_panel.previewInited) return; +#ifdef _WIN32 + DeleteCriticalSection(&g_panel.preview.cs); +#else + pthread_mutex_destroy(&g_panel.preview.mutex); +#endif + g_panel.previewInited = false; +} + +// Auditions the sample at bank index `idx`: stops any prior preview, loads the +// sample's file as a PCM_source, and starts stock preview playback. Re-audition +// (calling with a new idx while one plays) stops the previous first. On any +// failure (bad index, unsaved project, unreadable file, PlayPreview refusal) it +// leaves nothing playing and no source leaked. +void startAudition(int idx) { + // Always stop+free the previous first — re-audition semantics, and it clears + // previewSrc so the load below starts clean. + stopAudition(); + + const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr; + if (!bank) return; + const std::vector& samples = bank->all(); + if (idx < 0 || idx >= static_cast(samples.size())) return; + + const std::string projectDir = currentProjectDir(); + const std::string abs = resolveBankFile(projectDir, samples[idx].relativePath); + if (abs.empty()) return; // unsaved project / unresolvable — nothing to play + + PCM_source* src = PCM_Source_CreateFromFile(abs.c_str()); + if (!src) return; // unreadable file — no preview, no leak + + // Fill the register. cs/mutex already initialized (initPreview at panel open). + g_panel.preview.src = src; + g_panel.preview.m_out_chan = 0; // first hardware output pair (assumption #3) + g_panel.preview.curpos = 0.0; + g_panel.preview.loop = false; + g_panel.preview.volume = 1.0; + g_panel.preview.peakvol[0] = 0.0; + g_panel.preview.peakvol[1] = 0.0; + g_panel.preview.preview_track = nullptr; + + if (PlayPreview(&g_panel.preview) != 0) { + g_panel.previewSrc = src; // we now own it until stopAudition frees it + g_panel.previewActive = true; + } else { + // PlayPreview refused — free the source we created rather than leak it. + PCM_Source_Destroy(src); + g_panel.preview.src = nullptr; + } +} + +// --- Selection + input -------------------------------------------------------- + +// True while VK_CONTROL / VK_SHIFT is physically down. SWELL does NOT set MK_* bits +// in a mouse message's wParam (swell-types.h), so modifier state is read live via +// GetAsyncKeyState — the portable path (Win/mac/GDK all support these two VKs). +bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } +bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } + +// The current bank item count (0 when no session/bank). +int bankItemCount() { + const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr; + return bank ? static_cast(bank->size()) : 0; +} + +// Requests a repaint of the whole client area (selection/focus chrome changed). +void invalidatePanel() { + if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); +} + +// Handles a left-button click at client (x, y): hit-test to a cell, update the +// selection through the pure model with the live modifier state, repaint. A click +// on empty space (gap/margin/below grid) clears the selection AND stops audition +// (deselect stop path). READ-ONLY: never mutates the bank/project. +void handleClick(int x, int y) { + const std::vector rects = panelRects(); + const int hit = hitTestCell(x, y, rects); + const int count = bankItemCount(); + + if (hit < 0) { + // Click on empty space clears the selection and stops any audition. + if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { + g_panel.selection = Selection{}; + stopAudition(); + invalidatePanel(); + } + return; + } + + g_panel.selection = + applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count); + g_panel.selItemCount = count; + invalidatePanel(); +} + +// The column count for the panel's CURRENT client width (nav needs the same wrap +// the layout uses). >= 1. +int columnsNow() { + if (!g_panel.hwnd) return 1; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + return columnsForWidth(cr.right - cr.left, kGrid); +} + +// True iff `hwnd` is our panel window or a descendant of it (the accelerator hook +// only claims keys when focus is inside the panel). Walks the parent chain. +bool isOurWindow(HWND hwnd) { + for (HWND w = hwnd; w; w = GetParent(w)) + if (w == g_panel.hwnd) return true; + return false; +} + +// Handles a key-down (virtual key `vk`) while the panel is focused. Returns true if +// the key was consumed (arrow nav / Enter/Space audition / Esc stop), false to let +// REAPER handle it. Arrow keys mutate the selection through the pure nav model and +// repaint; Shift extends. READ-ONLY: never mutates the bank/project. +bool handleKey(int vk) { + const int count = bankItemCount(); + if (count <= 0) return false; + + switch (vk) { + case VK_LEFT: + case VK_RIGHT: + case VK_UP: + case VK_DOWN: { + const NavKey nk = vk == VK_LEFT ? NavKey::Left + : vk == VK_RIGHT ? NavKey::Right + : vk == VK_UP ? NavKey::Up + : NavKey::Down; + g_panel.selection = + navigate(g_panel.selection, nk, columnsNow(), count, shiftDown()); + g_panel.selItemCount = count; + invalidatePanel(); + return true; + } + case VK_RETURN: + case VK_SPACE: + // Audition the focused cell. Enter/Space with no focus does nothing + // (nothing to play). Re-audition stops the previous inside startAudition. + if (g_panel.selection.focus >= 0) + startAudition(g_panel.selection.focus); + return true; + case VK_ESCAPE: + // Stop audition (does not clear the selection — Esc is "stop", not + // "deselect"). No-op when nothing is playing; still consume so REAPER + // does not treat Esc as a global stop while the panel is focused. + stopAudition(); + return true; + default: + return false; + } +} + +// The keyboard accelerator hook (registered with "accelerator"). REAPER calls this +// for every keystroke; we claim arrow/Enter/Space/Esc ONLY when focus is inside the +// panel, eating them so REAPER does not steal arrows for the arrange. Returns 1 to +// eat, 0 to pass on (not our window / not our key). +int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) { + if (!msg || msg->message != WM_KEYDOWN) return 0; // key-down only + if (!g_panel.open || !g_panel.hwnd) return 0; + if (!isOurWindow(GetFocus())) return 0; // focus not in the panel + return handleKey(static_cast(msg->wParam)) ? 1 : 0; +} + +accelerator_register_t g_accel{translateAccel, true, nullptr}; +bool g_accelRegistered = false; + +// Registers the keyboard hook once (on first panel open). isLocal must be true +// (reaper_plugin.h). Safe to call repeatedly. +void registerAccel() { + if (g_accelRegistered || !g_rec) return; + g_rec->Register("accelerator", &g_accel); + g_accelRegistered = true; +} + +// Mirror-unregisters the keyboard hook on teardown. +void unregisterAccel() { + if (!g_accelRegistered || !g_rec) return; + g_rec->Register("-accelerator", &g_accel); + g_accelRegistered = false; +} + // --- Dialog proc + docking ---------------------------------------------------- WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { @@ -366,9 +689,23 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { EndPaint(hwnd, &ps); return 0; } + case WM_LBUTTONDOWN: { + // Take keyboard focus so the accelerator hook routes arrows/audition + // keys to us, then resolve the click. Coordinates are client-relative + // signed shorts in lParam (SWELL sets these even though it omits the + // MK_* modifier bits in wParam — hence GetAsyncKeyState for modifiers). + SetFocus(hwnd); + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + handleClick(x, y); + return 0; + } case WM_DESTROY: - // REAPER closed the dock (user X'd it). Reflect closed state so the - // toggle re-opens rather than trying to reuse a dead HWND. + // REAPER closed the dock (user X'd it). Stop any audition (window-close + // stop path — no preview may outlive the window) and reflect closed + // state so the toggle re-opens rather than reusing a dead HWND. + stopAudition(); + g_panel.selection = Selection{}; g_panel.hwnd = nullptr; g_panel.open = false; return 0; @@ -385,6 +722,9 @@ void openPanel() { } // Create the dialog as a child (WS_CHILD in the template); REAPER's docker // reparents it. lParam is unused (state lives in g_panel). + // Set up the preview register's lock ONCE before the window can audition. + initPreview(); + g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), GetMainHwnd(), dlgProc, 0); if (!g_panel.hwnd) return; @@ -396,12 +736,21 @@ void openPanel() { DockWindowActivate(g_panel.hwnd); g_panel.open = true; + // Start receiving arrow/audition keys while the panel is open. + registerAccel(); + // Prime the fingerprint so the first timer tick doesn't count the initial // bank as a "change" (it's already drawn on open). refreshFingerprint(); } void closePanel() { + // Stop audition before the window goes away (window-close stop path). WM_DESTROY + // also stops, but stop here too so a DockWindowRemove that suppresses WM_DESTROY + // still tears the preview down (idempotent: stopAudition no-ops if not playing). + stopAudition(); + g_panel.selection = Selection{}; + unregisterAccel(); if (g_panel.hwnd) { DockWindowRemove(g_panel.hwnd); DestroyWindow(g_panel.hwnd); @@ -438,7 +787,8 @@ void bankPanelRefresh() { } void bankPanelShutdown() { - closePanel(); + closePanel(); // stops audition + destroys the window + deinitPreview(); // destroy the preview lock (after the last stop) g_panel.cache.clear(); g_panel.session = nullptr; } diff --git a/tests/test_bank_grid.cpp b/tests/test_bank_grid.cpp index 1790a08..59d8cf3 100644 --- a/tests/test_bank_grid.cpp +++ b/tests/test_bank_grid.cpp @@ -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 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 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; }