Merge M5 Wave B: audition, multi-select, and keyboard nav
This commit is contained in:
@@ -2,8 +2,34 @@
|
|||||||
|
|
||||||
#include "bank_grid.h"
|
#include "bank_grid.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
namespace reasampler {
|
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) {
|
int columnsForWidth(int panelWidth, const GridSpec& spec) {
|
||||||
// Layout: [gap][cell][gap][cell]...[cell][gap]. n cells occupy
|
// Layout: [gap][cell][gap][cell]...[cell][gap]. n cells occupy
|
||||||
// gap + n*(cellWidth + gap). Solve for the largest n that fits panelWidth,
|
// gap + n*(cellWidth + gap). Solve for the largest n that fits panelWidth,
|
||||||
@@ -62,4 +88,115 @@ std::string thumbnailKeyString(const ThumbnailKey& key) {
|
|||||||
return s;
|
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
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -85,4 +85,82 @@ struct ThumbnailKey {
|
|||||||
// length-prefixed so an id containing the delimiter cannot collide with another).
|
// length-prefixed so an id containing the delimiter cannot collide with another).
|
||||||
std::string thumbnailKeyString(const ThumbnailKey& key);
|
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
|
} // namespace reasampler
|
||||||
|
|||||||
+358
-8
@@ -48,6 +48,9 @@
|
|||||||
// WDL_DLGRET (the platform dialog-proc return type).
|
// WDL_DLGRET (the platform dialog-proc return type).
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
||||||
|
#else
|
||||||
|
#include <pthread.h>
|
||||||
#endif
|
#endif
|
||||||
#include "wdltypes.h"
|
#include "wdltypes.h"
|
||||||
#include "swell/swell.h"
|
#include "swell/swell.h"
|
||||||
@@ -55,6 +58,11 @@
|
|||||||
|
|
||||||
#include "resource.h"
|
#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_MINIMAL
|
||||||
#define REAPERAPI_WANT_DockWindowAddEx
|
#define REAPERAPI_WANT_DockWindowAddEx
|
||||||
#define REAPERAPI_WANT_DockWindowActivate
|
#define REAPERAPI_WANT_DockWindowActivate
|
||||||
@@ -63,10 +71,17 @@
|
|||||||
#define REAPERAPI_WANT_GetMainHwnd
|
#define REAPERAPI_WANT_GetMainHwnd
|
||||||
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
|
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
|
||||||
#define REAPERAPI_WANT_PCM_Source_Destroy
|
#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"
|
#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_HINSTANCE g_hInst;
|
||||||
|
extern reaper_plugin_info_t* g_rec;
|
||||||
|
|
||||||
namespace reasampler {
|
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 kColWaveform = LICE_RGBA(120, 200, 160, 255);
|
||||||
const LICE_pixel kColMidline = LICE_RGBA(60, 60, 66, 255);
|
const LICE_pixel kColMidline = LICE_RGBA(60, 60, 66, 255);
|
||||||
const LICE_pixel kColText = LICE_RGBA(200, 200, 205, 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 --------------------------------------------------------------
|
// --- Panel state --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -122,10 +143,39 @@ struct PanelState {
|
|||||||
// Entries for stale generations are lazily overwritten on next miss; a bank
|
// Entries for stale generations are lazily overwritten on next miss; a bank
|
||||||
// change also clears it wholesale (see refreshFingerprint) to bound memory.
|
// change also clears it wholesale (see refreshFingerprint) to bound memory.
|
||||||
std::unordered_map<std::string, CachedThumbnail> cache;
|
std::unordered_map<std::string, CachedThumbnail> 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;
|
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) -------------
|
// --- Current-project directory (mirrors persist.cpp's derivation) -------------
|
||||||
//
|
//
|
||||||
// The index stores relative paths; resolving a bank file needs the current .rpp
|
// 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
|
// 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
|
// vertically (each channel gets an equal horizontal band) so a stereo sample shows
|
||||||
// both channels without folding (precision invariant: no stereo fold).
|
// both channels without folding (precision invariant: no stereo fold).
|
||||||
void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env) {
|
// `selected` tints the fill and brightens the border; `focused` overrides the
|
||||||
LICE_FillRect(bmp, rect.x, rect.y, rect.width, rect.height, kColCellBg, 1.0f, 0);
|
// border with the accent color so the caret cell reads within a multi-selection.
|
||||||
LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, kColCellBorder, 1.0f, 0);
|
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()) {
|
if (env.empty()) {
|
||||||
// Unreadable / empty sample: cell drawn, no waveform. A single midline
|
// 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);
|
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<CellRect> 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<int>(bank->size()), w, kGrid);
|
||||||
|
}
|
||||||
|
|
||||||
// The full paint: build/refresh the LICE backing bitmap at client size, draw the
|
// The full paint: build/refresh the LICE backing bitmap at client size, draw the
|
||||||
// grid (or empty state), then blit to the window HDC.
|
// grid (or empty state), then blit to the window HDC.
|
||||||
void paintPanel(HWND hwnd, HDC 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
|
// Skip cells entirely below the viewport (Wave A has no scroll; this
|
||||||
// just avoids computing thumbnails that cannot be seen).
|
// just avoids computing thumbnails that cannot be seen).
|
||||||
if (rect.y >= h) continue;
|
if (rect.y >= h) continue;
|
||||||
|
const int idx = static_cast<int>(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);
|
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.bankFingerprint = std::move(fp);
|
||||||
++g_panel.generation;
|
++g_panel.generation;
|
||||||
g_panel.cache.clear();
|
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<int>(g_panel.session->bank().size());
|
||||||
return true;
|
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<Sample>& samples = bank->all();
|
||||||
|
if (idx < 0 || idx >= static_cast<int>(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<int>(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<CellRect> 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<int>(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 ----------------------------------------------------
|
// --- Dialog proc + docking ----------------------------------------------------
|
||||||
|
|
||||||
WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
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);
|
EndPaint(hwnd, &ps);
|
||||||
return 0;
|
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:
|
case WM_DESTROY:
|
||||||
// REAPER closed the dock (user X'd it). Reflect closed state so the
|
// REAPER closed the dock (user X'd it). Stop any audition (window-close
|
||||||
// toggle re-opens rather than trying to reuse a dead HWND.
|
// 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.hwnd = nullptr;
|
||||||
g_panel.open = false;
|
g_panel.open = false;
|
||||||
return 0;
|
return 0;
|
||||||
@@ -385,6 +722,9 @@ void openPanel() {
|
|||||||
}
|
}
|
||||||
// Create the dialog as a child (WS_CHILD in the template); REAPER's docker
|
// Create the dialog as a child (WS_CHILD in the template); REAPER's docker
|
||||||
// reparents it. lParam is unused (state lives in g_panel).
|
// 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),
|
g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL),
|
||||||
GetMainHwnd(), dlgProc, 0);
|
GetMainHwnd(), dlgProc, 0);
|
||||||
if (!g_panel.hwnd) return;
|
if (!g_panel.hwnd) return;
|
||||||
@@ -396,12 +736,21 @@ void openPanel() {
|
|||||||
DockWindowActivate(g_panel.hwnd);
|
DockWindowActivate(g_panel.hwnd);
|
||||||
g_panel.open = true;
|
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
|
// Prime the fingerprint so the first timer tick doesn't count the initial
|
||||||
// bank as a "change" (it's already drawn on open).
|
// bank as a "change" (it's already drawn on open).
|
||||||
refreshFingerprint();
|
refreshFingerprint();
|
||||||
}
|
}
|
||||||
|
|
||||||
void closePanel() {
|
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) {
|
if (g_panel.hwnd) {
|
||||||
DockWindowRemove(g_panel.hwnd);
|
DockWindowRemove(g_panel.hwnd);
|
||||||
DestroyWindow(g_panel.hwnd);
|
DestroyWindow(g_panel.hwnd);
|
||||||
@@ -438,7 +787,8 @@ void bankPanelRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void bankPanelShutdown() {
|
void bankPanelShutdown() {
|
||||||
closePanel();
|
closePanel(); // stops audition + destroys the window
|
||||||
|
deinitPreview(); // destroy the preview lock (after the last stop)
|
||||||
g_panel.cache.clear();
|
g_panel.cache.clear();
|
||||||
g_panel.session = nullptr;
|
g_panel.session = nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
// itemCount == 0; a single item; a panel too narrow for even one cell (clamp to
|
// 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,
|
// one column); content-height for exact and partial rows; cache-key stability,
|
||||||
// width/generation/id sensitivity, and length-prefix collision resistance.
|
// 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"
|
#include "../src/bank_grid.h"
|
||||||
|
|
||||||
@@ -128,6 +133,195 @@ static void testCacheKeyDelimiterCollisionResistance() {
|
|||||||
CHECK(thumbnailKeyString(k1) != thumbnailKeyString(k2));
|
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() {
|
int main() {
|
||||||
testColumnsForWidth();
|
testColumnsForWidth();
|
||||||
testTooNarrowClampsToOneColumn();
|
testTooNarrowClampsToOneColumn();
|
||||||
@@ -139,6 +333,28 @@ int main() {
|
|||||||
testCacheKeyStabilityAndSensitivity();
|
testCacheKeyStabilityAndSensitivity();
|
||||||
testCacheKeyDelimiterCollisionResistance();
|
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");
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user