feat(bank_panel): B4 vertical-split UI — LICE tab strip, id-keyed bank ops, move/copy drag
Pool grid on top, LICE-drawn named-banks tab strip below with overflow-scroll (new pure tab_strip seam, unit-tested). Full-height toggles, unmistakable active-bank readout distinct from the shown tab, tab context menu (activate/rename/delete/evacuate/create) with rich confirm-on-non-empty-delete, and move/copy via menu + drag with drop-highlighting. Fold-in: deserialize auto-disambiguates duplicate folded bank names instead of rejecting the book.
This commit is contained in:
+17
-1
@@ -55,6 +55,17 @@ target_include_directories(bank_grid PUBLIC src)
|
||||
add_library(mode_switch STATIC src/mode_switch.cpp)
|
||||
target_include_directories(mode_switch PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2c'') Pure tab_strip layout — NO REAPER, NO SWELL. The named-banks tab-strip
|
||||
# geometry (B4): strip rect + N tabs at a fixed tab width + scroll offset ->
|
||||
# per-tab rects (overflow-clipped), overflow chevron reservation + maxScroll,
|
||||
# and point -> tab / chevron hit-test. Split out so the strip's layout +
|
||||
# overflow/scroll math is unit-tested outside the DAW; the bank_panel region
|
||||
# that draws it and routes clicks is DAW-verified. Mirror of mode_switch.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(tab_strip STATIC src/tab_strip.cpp)
|
||||
target_include_directories(tab_strip PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2d) Pure view_mode_model library — NO REAPER, NO SWELL. The Design View heart
|
||||
# (Phase D1): mode registry + GUID-keyed membership index + folder-tree-aware
|
||||
@@ -156,6 +167,10 @@ add_executable(mode_switch_tests tests/test_mode_switch.cpp)
|
||||
target_link_libraries(mode_switch_tests PRIVATE mode_switch)
|
||||
add_test(NAME mode_switch_tests COMMAND mode_switch_tests)
|
||||
|
||||
add_executable(tab_strip_tests tests/test_tab_strip.cpp)
|
||||
target_link_libraries(tab_strip_tests PRIVATE tab_strip)
|
||||
add_test(NAME tab_strip_tests COMMAND tab_strip_tests)
|
||||
|
||||
add_executable(view_mode_model_tests tests/test_view_mode_model.cpp)
|
||||
target_link_libraries(view_mode_model_tests PRIVATE view_mode_model)
|
||||
add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests)
|
||||
@@ -206,6 +221,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/persist.cpp
|
||||
src/bank_panel.cpp
|
||||
src/mode_switch.cpp
|
||||
src/tab_strip.cpp
|
||||
src/insert.cpp
|
||||
src/insert_plan.cpp
|
||||
${LICE_SRC}
|
||||
@@ -216,7 +232,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/actions.cpp
|
||||
src/bank_book.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record bank_book)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||
|
||||
|
||||
+9
-8
@@ -531,12 +531,13 @@ void doBankActivatePool() {
|
||||
ShowConsoleMsg("ReaSampler: active bank -> \"Pool\".\n");
|
||||
}
|
||||
|
||||
// Move or copy the panel's selected samples from the ACTIVE bank into a named
|
||||
// destination bank (prompted by display name). The panel grid shows the active bank,
|
||||
// so its selection ids are members of the active bank — that is the source. Both are
|
||||
// Move or copy the panel's selected samples into a named destination bank (prompted
|
||||
// by display name). The SOURCE is the bank the selection lives in — the focused
|
||||
// region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical
|
||||
// split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are
|
||||
// index-only (files never relocate); move removes the source entry, copy retains it;
|
||||
// both observe destination collapse-by-hash (bank_book). B4's "move to bank" menu will
|
||||
// drive moveSample/copySample directly with a menu-chosen destination — this bindable
|
||||
// both observe destination collapse-by-hash (bank_book). B4's "move to bank" menu
|
||||
// drives moveSample/copySample directly with a menu-chosen destination — this bindable
|
||||
// form is the same operation with a text-prompt destination.
|
||||
void doBankTransferSelected(bool copy) {
|
||||
const std::vector<std::string> selected = bankPanelSelectedSampleIds();
|
||||
@@ -554,9 +555,9 @@ void doBankTransferSelected(bool copy) {
|
||||
ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str());
|
||||
return;
|
||||
}
|
||||
// Source = the active bank (what the panel grid shows). Pass ids by value — no
|
||||
// BankIndex& is cached across the loop's mutations.
|
||||
const std::string srcId = g_session->book().activeBankId();
|
||||
// Source = the bank the selection lives in (the focused region's displayed bank).
|
||||
// Pass ids by value — no BankIndex& is cached across the loop's mutations.
|
||||
const std::string srcId = bankPanelSelectedSourceBankId();
|
||||
if (srcId == destId) {
|
||||
ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n");
|
||||
return;
|
||||
|
||||
@@ -687,6 +687,49 @@ bool Parser::parseBook(std::vector<Bank>& banks, std::string& activeBank) {
|
||||
for (auto& b : parsedBanks)
|
||||
if (b.isPool()) b.displayName = kPoolBankName;
|
||||
|
||||
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
|
||||
// The in-model create/rename path enforces unique display names under nameKey,
|
||||
// but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
|
||||
// same key ("Drums" and " drums "). Rejecting the whole book over one collision
|
||||
// would degrade the user's entire library to empty, so instead we AUTO-
|
||||
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
|
||||
// the first time a folded key repeats, suffix that bank's display name (" 2",
|
||||
// " 3", …) until its folded key is unique among all names seen so far. The FIRST
|
||||
// bank to carry a key keeps its name verbatim; only subsequent collisions are
|
||||
// renamed. No bank or sample is lost, and ids are untouched. The pool is included
|
||||
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
|
||||
// is disambiguated away from it, never the reverse.
|
||||
{
|
||||
std::vector<std::string> seenKeys;
|
||||
seenKeys.reserve(parsedBanks.size());
|
||||
for (auto& b : parsedBanks) {
|
||||
if (b.isPool()) { // pool's name is fixed; reserve its key
|
||||
seenKeys.push_back(nameKey(b.displayName));
|
||||
continue;
|
||||
}
|
||||
const auto taken = [&](const std::string& k) {
|
||||
return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end();
|
||||
};
|
||||
std::string key = nameKey(b.displayName);
|
||||
if (taken(key)) {
|
||||
// Suffix with an ascending integer until the folded key is free. Guard
|
||||
// against a pathological blob whose base name already ends in a number
|
||||
// by folding the candidate each attempt (nameKey normalizes it).
|
||||
const std::string base = b.displayName;
|
||||
for (int n = 2;; ++n) {
|
||||
const std::string candidate = base + " " + std::to_string(n);
|
||||
const std::string candKey = nameKey(candidate);
|
||||
if (!taken(candKey)) {
|
||||
b.displayName = candidate;
|
||||
key = candKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
seenKeys.push_back(key);
|
||||
}
|
||||
}
|
||||
|
||||
banks = std::move(parsedBanks);
|
||||
return true;
|
||||
}
|
||||
|
||||
+1102
-440
File diff suppressed because it is too large
Load Diff
@@ -43,8 +43,23 @@ bool bankPanelIsOpen();
|
||||
// Note: the panel's selection is cleared on a bank change (capture / project
|
||||
// load), so a returned id always names a sample present in the current bank at
|
||||
// the moment of the call; the caller still tolerates an absent id gracefully.
|
||||
//
|
||||
// Phase B4 (vertical split): the selection lives in whichever REGION the user last
|
||||
// interacted with (the pool grid on top or a named-bank grid below), which is NOT
|
||||
// necessarily the active/capture-target bank. The returned ids therefore name
|
||||
// samples in the FOCUSED region's displayed bank — the bank the user visibly
|
||||
// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those
|
||||
// ids belong to (the move/copy source).
|
||||
std::vector<std::string> bankPanelSelectedSampleIds();
|
||||
|
||||
// The bank id the current selection belongs to — the displayed bank of the region
|
||||
// the user last interacted with (pool region -> the pool id; named-banks region ->
|
||||
// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current
|
||||
// selection, and it is distinct from the active/capture-target bank (active ≠ shown).
|
||||
// Returns the pool id when nothing is selected or the panel has never opened (a safe
|
||||
// default source). READ of panel state only; no mutation.
|
||||
std::string bankPanelSelectedSourceBankId();
|
||||
|
||||
// Requests a repaint if the bank changed since the last paint (generation bump).
|
||||
// Cheap when nothing changed. Driven by the timer so a capture / project load is
|
||||
// reflected without the panel diffing the bank itself.
|
||||
|
||||
+7
-1
@@ -125,7 +125,13 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
|
||||
const std::string projectDir = currentProjectDir();
|
||||
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
|
||||
|
||||
const BankIndex& bank = session->bank();
|
||||
// Resolve the id against the bank the SELECTION came from — under B4's vertical
|
||||
// split the selection may live in the pool or a shown named bank, which is NOT
|
||||
// necessarily the active/capture-target bank. Fall back to the active bank when
|
||||
// the source id names no bank (defensive).
|
||||
const std::string srcBankId = bankPanelSelectedSourceBankId();
|
||||
const BankIndex* srcIndex = session->book().index(srcBankId);
|
||||
const BankIndex& bank = srcIndex ? *srcIndex : session->bank();
|
||||
const Sample* sample = bank.query(id);
|
||||
if (!sample) { result.status = InsertStatus::NothingResolved; return result; }
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// tab_strip — pure implementation. See tab_strip.h. NO REAPER / SWELL / vendor.
|
||||
|
||||
#include "tab_strip.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset) {
|
||||
(void)scrollOffset; // layout depends on geometry only, not the current offset
|
||||
TabStripLayout out;
|
||||
if (tabCount <= 0 || strip.width <= 0) {
|
||||
out.trackX = strip.x;
|
||||
out.trackWidth = strip.width > 0 ? strip.width : 0;
|
||||
return out; // nothing to lay out: track == strip, no overflow, no chevrons
|
||||
}
|
||||
|
||||
const int totalTabsWidth = tabCount * spec.tabWidth;
|
||||
if (totalTabsWidth <= strip.width) {
|
||||
// Everything fits: the whole strip is the track; no chevrons, no scroll.
|
||||
out.overflow = false;
|
||||
out.trackX = strip.x;
|
||||
out.trackWidth = strip.width;
|
||||
out.maxScroll = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Overflow: reserve a chevron band at each end; the tabs live between them.
|
||||
out.overflow = true;
|
||||
out.leftChevron = true;
|
||||
out.rightChevron = true;
|
||||
out.trackX = strip.x + spec.chevronWidth;
|
||||
out.trackWidth = strip.width - 2 * spec.chevronWidth;
|
||||
if (out.trackWidth < 0) out.trackWidth = 0;
|
||||
// The tab run exceeds the track by this many pixels; the strip may scroll exactly
|
||||
// that far so the last tab's right edge reaches the track's right edge, no more.
|
||||
out.maxScroll = totalTabsWidth - out.trackWidth;
|
||||
if (out.maxScroll < 0) out.maxScroll = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
int clampTabScroll(int desiredOffset, const TabStripLayout& layout) {
|
||||
if (desiredOffset < 0) return 0;
|
||||
if (desiredOffset > layout.maxScroll) return layout.maxScroll;
|
||||
return desiredOffset;
|
||||
}
|
||||
|
||||
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset) {
|
||||
std::vector<TabRect> rects;
|
||||
if (tabCount <= 0 || strip.width <= 0) return rects;
|
||||
|
||||
const TabStripLayout layout =
|
||||
computeTabStripLayout(strip, tabCount, spec, scrollOffset);
|
||||
const int offset = layout.overflow ? clampTabScroll(scrollOffset, layout) : 0;
|
||||
const int trackLeft = layout.trackX;
|
||||
const int trackRight = layout.trackX + layout.trackWidth;
|
||||
|
||||
rects.reserve(static_cast<std::size_t>(tabCount));
|
||||
for (int i = 0; i < tabCount; ++i) {
|
||||
const int rawLeft = trackLeft + i * spec.tabWidth - offset;
|
||||
const int rawRight = rawLeft + spec.tabWidth;
|
||||
// Clip to the track: a partially-scrolled tab must not draw under a chevron
|
||||
// or spill past the track. A tab whose clipped extent is empty is omitted.
|
||||
int left = rawLeft < trackLeft ? trackLeft : rawLeft;
|
||||
int right = rawRight > trackRight ? trackRight : rawRight;
|
||||
if (right <= left) continue; // fully scrolled out of view either side
|
||||
TabRect r;
|
||||
r.index = i;
|
||||
r.x = left;
|
||||
r.y = strip.y;
|
||||
r.width = right - left;
|
||||
r.height = strip.height;
|
||||
rects.push_back(r);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset) {
|
||||
TabHit miss; // {None, -1}
|
||||
if (tabCount <= 0 || strip.width <= 0 || strip.height <= 0) return miss;
|
||||
|
||||
// Reject anything outside the strip band first (half-open bounds).
|
||||
if (px < strip.x || px >= strip.x + strip.width ||
|
||||
py < strip.y || py >= strip.y + strip.height)
|
||||
return miss;
|
||||
|
||||
const TabStripLayout layout =
|
||||
computeTabStripLayout(strip, tabCount, spec, scrollOffset);
|
||||
|
||||
// Chevrons take precedence at the strip ends: a click in a reserved chevron band
|
||||
// is a scroll, never a tab (the tab track excludes those bands).
|
||||
if (layout.overflow) {
|
||||
if (px < strip.x + spec.chevronWidth)
|
||||
return TabHit{TabHitKind::ScrollLeft, -1};
|
||||
if (px >= strip.x + strip.width - spec.chevronWidth)
|
||||
return TabHit{TabHitKind::ScrollRight, -1};
|
||||
}
|
||||
|
||||
// Inside the track: find the visible tab whose clipped rect contains px. Reuse
|
||||
// computeTabRects so the hit matches exactly what was drawn (clipping included).
|
||||
const std::vector<TabRect> rects =
|
||||
computeTabRects(strip, tabCount, spec, scrollOffset);
|
||||
for (const TabRect& r : rects) {
|
||||
if (px >= r.x && px < r.x + r.width) return TabHit{TabHitKind::Tab, r.index};
|
||||
}
|
||||
return miss; // track dead space (no tab under the point)
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
#pragma once
|
||||
// tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's
|
||||
// named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the
|
||||
// vertical-split bank window is a LICE-drawn tab strip (one tab per named bank,
|
||||
// NOT a SWELL-native tab control), and — from the start — it must scroll when the
|
||||
// tabs overflow the strip width (a naive fixed-width strip breaks down at ~8–12
|
||||
// tabs). What is NOT DAW-bound — how N fixed-width tabs tile a strip of a given
|
||||
// pixel width, where the overflow chevrons sit, which tab/chevron a click lands in,
|
||||
// and how far the strip may scroll — lives here so it is unit-tested outside the
|
||||
// DAW (CLAUDE.md §load-bearing split). The panel shell (bank_panel.cpp) owns the
|
||||
// SWELL window, LICE drawing, and the live BankBook read; it calls into this seam
|
||||
// for every rect and every hit. Mirror of mode_switch / bank_grid.
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only. Builds and unit-tests without REAPER.
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The strip the tabs are drawn into, top-left origin (SWELL/LICE convention).
|
||||
// (x, y) is the top-left corner; width/height are the strip extents. The panel
|
||||
// reserves this as a fixed-height band at the top of the named-banks region.
|
||||
struct TabStripRect {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const TabStripRect& o) const {
|
||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed
|
||||
// so the strip reads as a uniform segmented control and overflow math stays simple —
|
||||
// labels ellipsize within the tab, they do not resize it). chevronWidth is the width
|
||||
// reserved at each end for the scroll affordance WHEN the tabs overflow; when they
|
||||
// fit, no chevron is reserved and the tabs use the full strip width.
|
||||
struct TabStripSpec {
|
||||
int tabWidth = 96;
|
||||
int chevronWidth = 20;
|
||||
};
|
||||
|
||||
// One tab's pixel rectangle within the strip, top-left origin, ALREADY translated
|
||||
// by the current scroll offset and clipped to the visible track. `index` is the
|
||||
// tab's index in the caller's list (ordinal order) so the shell can label/light it
|
||||
// without re-deriving. A tab scrolled fully out of view is omitted from the result
|
||||
// (the shell only draws what computeTabRects returns), so every returned rect is at
|
||||
// least partially visible.
|
||||
struct TabRect {
|
||||
int index = 0;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const TabRect& o) const {
|
||||
return index == o.index && x == o.x && y == o.y &&
|
||||
width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// The scrollable track's geometry: where the tabs may be drawn (between the
|
||||
// chevrons when overflowing, or the whole strip when they fit) and whether each
|
||||
// chevron is present. Derived once and shared by layout + hit-testing so both agree.
|
||||
struct TabStripLayout {
|
||||
bool overflow = false; // true iff N tabs at tabWidth exceed the track width
|
||||
int trackX = 0; // left edge of the tab track (past the left chevron)
|
||||
int trackWidth = 0; // width available to tabs (strip minus both chevrons)
|
||||
int maxScroll = 0; // largest valid scroll offset (0 when no overflow)
|
||||
bool leftChevron = false; // a left-scroll affordance is reserved this frame
|
||||
bool rightChevron = false;// a right-scroll affordance is reserved this frame
|
||||
};
|
||||
|
||||
// Computes the strip layout for `tabCount` tabs of `spec.tabWidth` in `strip`,
|
||||
// given the current `scrollOffset`. Pure geometry:
|
||||
// * No overflow (all tabs fit the strip width): overflow=false, no chevrons, the
|
||||
// track IS the strip, maxScroll=0.
|
||||
// * Overflow: both chevrons are reserved (chevronWidth each), the track is the
|
||||
// strip minus both chevrons, and maxScroll is the pixels by which the tab run
|
||||
// exceeds the track (so the last tab's right edge can reach the track's right
|
||||
// edge but not scroll past it). Chevrons are always both present under overflow
|
||||
// (a fixed affordance is simpler and unambiguous than hiding one at an end;
|
||||
// clicking a chevron at a scroll limit is a harmless no-op the shell clamps).
|
||||
// tabCount <= 0 or a non-positive strip width returns a zeroed layout (no overflow,
|
||||
// track == strip, maxScroll 0).
|
||||
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
// Clamps a desired scroll offset into [0, maxScroll] for the given layout. The shell
|
||||
// calls this after a chevron click / wheel so the strip never scrolls past either
|
||||
// end. maxScroll is 0 when the tabs fit, so a fitting strip always clamps to 0.
|
||||
int clampTabScroll(int desiredOffset, const TabStripLayout& layout);
|
||||
|
||||
// Tiles `tabCount` fixed-width tabs left-to-right into the layout's track, shifted
|
||||
// left by `scrollOffset`, and returns the rects that are at least partially visible
|
||||
// (in tab-index order). Each tab i sits at trackX + i*tabWidth - scrollOffset; a tab
|
||||
// whose visible extent is empty (fully left of or right of the track) is omitted.
|
||||
// Returned rects are CLIPPED to the track horizontally so a partially-scrolled tab
|
||||
// does not draw under a chevron. The caller passes the SAME scrollOffset it passed
|
||||
// to computeTabStripLayout (the shell clamps once, then uses the clamped value for
|
||||
// both). tabCount <= 0 -> empty.
|
||||
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
// What a point in the strip resolves to.
|
||||
enum class TabHitKind {
|
||||
None, // outside the strip, or in dead space between visible tabs
|
||||
Tab, // a tab — `index` is the tab's index in the caller's list
|
||||
ScrollLeft, // the left overflow chevron
|
||||
ScrollRight, // the right overflow chevron
|
||||
};
|
||||
|
||||
// The outcome of hit-testing a point against the strip. For Tab, `index` is the tab
|
||||
// index; for the chevrons and None it is -1.
|
||||
struct TabHit {
|
||||
TabHitKind kind = TabHitKind::None;
|
||||
int index = -1;
|
||||
|
||||
bool operator==(const TabHit& o) const {
|
||||
return kind == o.kind && index == o.index;
|
||||
}
|
||||
};
|
||||
|
||||
// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out
|
||||
// for `tabCount` tabs at `scrollOffset`. Chevrons take precedence over tabs at the
|
||||
// strip ends (a click in the reserved chevron band is a scroll, never a tab), and a
|
||||
// point outside the strip band, or in the track but not on any visible tab, is None.
|
||||
// Half-open bounds match computeTabRects / the chevron bands so no pixel is claimed
|
||||
// twice. The shell passes the SAME clamped scrollOffset it drew with.
|
||||
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -521,6 +521,123 @@ static void testActiveBankResolveAfterCorruptPersistedId() {
|
||||
CHECK(back && back->activeBankId() == std::string(kPoolBankId));
|
||||
}
|
||||
|
||||
// --- B4 fold-in: deserialize coalesces duplicate folded display names --------
|
||||
//
|
||||
// The in-model create/rename path enforces unique display names under the trimmed +
|
||||
// case-insensitive fold, but a hand-edited .rpp blob can carry two banks whose names
|
||||
// fold to the same key. deserialize must NOT reject the whole book (that would drop
|
||||
// the user's entire library over one collision) — it AUTO-DISAMBIGUATES the later
|
||||
// duplicate deterministically so the book loads intact with unique names, all banks
|
||||
// and samples preserved, and ids untouched.
|
||||
|
||||
// Rewrites the first occurrence of `from` in `s` to `to` (test helper: injects a
|
||||
// colliding display name into a serialized blob to simulate a hand-edit).
|
||||
static std::string replaceFirst(std::string s, const std::string& from,
|
||||
const std::string& to) {
|
||||
const auto pos = s.find(from);
|
||||
if (pos != std::string::npos) s.replace(pos, from.size(), to);
|
||||
return s;
|
||||
}
|
||||
|
||||
static void testDeserializeCoalescesDuplicateFoldedNames() {
|
||||
// Build a real book with two distinctly-named banks each holding a sample, then
|
||||
// corrupt the second bank's display name so it folds to the first's key
|
||||
// (" drums " folds to "drums", same as "Drums"). This is exactly what a
|
||||
// hand-edited blob would look like.
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "Drums"));
|
||||
CHECK(book.createBank("b", "Bass"));
|
||||
CHECK(book.bank("a")->index.add(sampleWith("a1")) == AddResult::Added);
|
||||
CHECK(book.bank("b")->index.add(sampleWith("b1")) == AddResult::Added);
|
||||
|
||||
const std::string json = book.serialize();
|
||||
// Rename bank "b" from "Bass" to " drums " (folds to "drums") — a duplicate of "a".
|
||||
const std::string corrupted =
|
||||
replaceFirst(json, "\"displayName\":\"Bass\"", "\"displayName\":\" drums \"");
|
||||
CHECK(corrupted != json); // the substitution landed
|
||||
|
||||
auto back = BankBook::deserialize(corrupted);
|
||||
CHECK(back.has_value());
|
||||
if (!back) return;
|
||||
|
||||
// The book loaded intact: pool + 2 named banks, no bank lost.
|
||||
CHECK(back->size() == 3);
|
||||
// Ids are preserved (disambiguation touches names only, never ids).
|
||||
CHECK(back->bank("a") != nullptr);
|
||||
CHECK(back->bank("b") != nullptr);
|
||||
// The FIRST bank to carry the folded key keeps its name; the later one is
|
||||
// suffixed to a unique name.
|
||||
CHECK(back->bank("a")->displayName == "Drums");
|
||||
CHECK(back->bank("b")->displayName != back->bank("a")->displayName);
|
||||
|
||||
// The disambiguated names are genuinely unique under the model's own fold — the
|
||||
// book can now round-trip through the in-model uniqueness invariant. Prove it by
|
||||
// re-serializing and re-parsing: idempotent, no further renames.
|
||||
const std::string json2 = back->serialize();
|
||||
auto back2 = BankBook::deserialize(json2);
|
||||
CHECK(back2.has_value());
|
||||
if (back2) CHECK(back2->serialize() == json2);
|
||||
|
||||
// No sample was lost across the coalesce.
|
||||
CHECK(back->bank("a")->index.size() == 1);
|
||||
CHECK(back->bank("b")->index.size() == 1);
|
||||
CHECK(back->bank("a")->index.query("id-a1") != nullptr);
|
||||
CHECK(back->bank("b")->index.query("id-b1") != nullptr);
|
||||
}
|
||||
|
||||
static void testDeserializeCoalescesMultipleCollisions() {
|
||||
// Three banks all folding to the same key: the first keeps its name, the next two
|
||||
// get distinct suffixes so all three end unique (no two disambiguate to the same).
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "Drums"));
|
||||
CHECK(book.createBank("b", "Bass"));
|
||||
CHECK(book.createBank("c", "Keys"));
|
||||
|
||||
std::string json = book.serialize();
|
||||
json = replaceFirst(json, "\"displayName\":\"Bass\"", "\"displayName\":\"drums\"");
|
||||
json = replaceFirst(json, "\"displayName\":\"Keys\"", "\"displayName\":\"DRUMS\"");
|
||||
|
||||
auto back = BankBook::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
if (!back) return;
|
||||
CHECK(back->size() == 4); // pool + 3, none lost
|
||||
|
||||
// All three named banks carry distinct folded keys after coalesce.
|
||||
const std::string na = back->bank("a")->displayName;
|
||||
const std::string nb = back->bank("b")->displayName;
|
||||
const std::string nc = back->bank("c")->displayName;
|
||||
CHECK(na != nb);
|
||||
CHECK(na != nc);
|
||||
CHECK(nb != nc);
|
||||
|
||||
// Re-parse proves the result satisfies the round-trip (unique keys throughout).
|
||||
auto back2 = BankBook::deserialize(back->serialize());
|
||||
CHECK(back2.has_value());
|
||||
if (back2) CHECK(back2->serialize() == back->serialize());
|
||||
}
|
||||
|
||||
static void testDeserializeNamedBankCollidingWithPoolIsDisambiguated() {
|
||||
// A named bank whose name folds to the pool's reserved "Pool" key is renamed away
|
||||
// from the pool (never the reverse — the pool's name is fixed and reserved).
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "Drums"));
|
||||
std::string json = book.serialize();
|
||||
json = replaceFirst(json, "\"displayName\":\"Drums\"", "\"displayName\":\"pool\"");
|
||||
|
||||
auto back = BankBook::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
if (!back) return;
|
||||
CHECK(back->size() == 2);
|
||||
// The pool keeps its authoritative name; the named bank is disambiguated off it.
|
||||
CHECK(back->pool().displayName == std::string(kPoolBankName));
|
||||
CHECK(back->bank("a") != nullptr);
|
||||
CHECK(back->bank("a")->displayName != std::string(kPoolBankName));
|
||||
// And it is not any case/space variant that would re-collide with "Pool".
|
||||
auto back2 = BankBook::deserialize(back->serialize());
|
||||
CHECK(back2.has_value());
|
||||
if (back2) CHECK(back2->serialize() == back->serialize());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPoolSeededAndDefaults();
|
||||
testPoolPrivileges();
|
||||
@@ -547,6 +664,9 @@ int main() {
|
||||
testCycleUnknownActiveResolvesToFirst();
|
||||
testCycleEmptyListYieldsEmpty();
|
||||
testCycleMatchesBookOrdinalOrder();
|
||||
testDeserializeCoalescesDuplicateFoldedNames();
|
||||
testDeserializeCoalescesMultipleCollisions();
|
||||
testDeserializeNamedBankCollidingWithPoolIsDisambiguated();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Standalone tests for reasampler::tab_strip — no REAPER, no test framework. Same
|
||||
// fast loop as the sibling pure tests (mode_switch / bank_grid et al.): assert the
|
||||
// named-banks tab-strip layout, overflow/scroll math, and hit-testing directly.
|
||||
//
|
||||
// Covers (B4 brief §unit-test the pure seam): no-overflow tiling (tabs fit, no
|
||||
// chevrons, track == strip); overflow (chevrons reserved, track shrinks, maxScroll
|
||||
// = run - track); scroll clamping to [0, maxScroll]; clipped visible rects (a
|
||||
// partially-scrolled tab is clipped to the track, a fully-scrolled-out tab is
|
||||
// omitted); scrolling to the end surfaces the last tab; hit-testing (tab hit,
|
||||
// left/right chevron precedence at the ends, dead space between visible tabs,
|
||||
// outside the band above/below/left/right, half-open boundary pixels).
|
||||
|
||||
#include "../src/tab_strip.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- No overflow: tabs fit ---------------------------------------------------
|
||||
|
||||
// 3 tabs at 96px = 288 fit a 300-wide strip: no overflow, no chevrons, the whole
|
||||
// strip is the track, maxScroll 0.
|
||||
static void testFitsNoOverflow() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
TabStripLayout layout = computeTabStripLayout(strip, 3, spec, 0);
|
||||
CHECK(!layout.overflow);
|
||||
CHECK(!layout.leftChevron && !layout.rightChevron);
|
||||
CHECK(layout.trackX == 0);
|
||||
CHECK(layout.trackWidth == 300);
|
||||
CHECK(layout.maxScroll == 0);
|
||||
|
||||
auto rects = computeTabRects(strip, 3, spec, 0);
|
||||
CHECK(rects.size() == 3);
|
||||
CHECK((rects[0] == TabRect{0, 0, 0, 96, 24}));
|
||||
CHECK((rects[1] == TabRect{1, 96, 0, 96, 24}));
|
||||
CHECK((rects[2] == TabRect{2, 192, 0, 96, 24}));
|
||||
}
|
||||
|
||||
// A fitting strip ignores a stray non-zero scroll offset (maxScroll 0 clamps it).
|
||||
static void testFitStripIgnoresScroll() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
auto rects = computeTabRects(strip, 3, spec, /*scrollOffset=*/500);
|
||||
CHECK(rects.size() == 3);
|
||||
CHECK(rects[0].x == 0); // offset was clamped to 0
|
||||
}
|
||||
|
||||
// --- Overflow: chevrons reserved, track shrinks ------------------------------
|
||||
|
||||
// 10 tabs at 96 = 960 overflow a 300-wide strip. Chevrons (20 each) are reserved,
|
||||
// so the track is [20, 280) = 260 wide; maxScroll = 960 - 260 = 700.
|
||||
static void testOverflowReservesChevrons() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
TabStripLayout layout = computeTabStripLayout(strip, 10, spec, 0);
|
||||
CHECK(layout.overflow);
|
||||
CHECK(layout.leftChevron && layout.rightChevron);
|
||||
CHECK(layout.trackX == 20);
|
||||
CHECK(layout.trackWidth == 260);
|
||||
CHECK(layout.maxScroll == 700);
|
||||
}
|
||||
|
||||
// At scroll 0 the first tabs are visible from the track's left edge; a tab that
|
||||
// straddles the right chevron is clipped to the track's right edge, and tabs fully
|
||||
// past it are omitted.
|
||||
static void testOverflowScrollZeroClipsRight() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
auto rects = computeTabRects(strip, 10, spec, 0);
|
||||
// Track is [20, 280). Tab 0 at [20,116), tab 1 [116,212), tab 2 [212,308) clipped
|
||||
// to [212,280). Tabs 3.. start past 280 -> omitted.
|
||||
CHECK(rects.size() == 3);
|
||||
CHECK((rects[0] == TabRect{0, 20, 0, 96, 24}));
|
||||
CHECK((rects[1] == TabRect{1, 116, 0, 96, 24}));
|
||||
CHECK((rects[2] == TabRect{2, 212, 0, 68, 24})); // clipped at the track's right
|
||||
}
|
||||
|
||||
// Scrolling to maxScroll surfaces the LAST tab flush against the track's right edge
|
||||
// and drops the earliest tabs off the left. This is the property that makes overflow
|
||||
// usable: every tab is reachable by scrolling.
|
||||
static void testScrollToEndSurfacesLastTab() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
TabStripLayout layout = computeTabStripLayout(strip, 10, spec, 0);
|
||||
auto rects = computeTabRects(strip, 10, spec, layout.maxScroll);
|
||||
CHECK(!rects.empty());
|
||||
const TabRect& last = rects.back();
|
||||
CHECK(last.index == 9); // the last tab is visible
|
||||
CHECK(last.x + last.width == layout.trackX + layout.trackWidth); // flush right (280)
|
||||
}
|
||||
|
||||
// --- Scroll clamping ---------------------------------------------------------
|
||||
|
||||
static void testClampScroll() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
TabStripLayout layout = computeTabStripLayout(strip, 10, spec, 0);
|
||||
CHECK(clampTabScroll(-50, layout) == 0);
|
||||
CHECK(clampTabScroll(0, layout) == 0);
|
||||
CHECK(clampTabScroll(300, layout) == 300);
|
||||
CHECK(clampTabScroll(layout.maxScroll, layout) == layout.maxScroll);
|
||||
CHECK(clampTabScroll(layout.maxScroll + 999, layout) == layout.maxScroll);
|
||||
|
||||
TabStripLayout fits = computeTabStripLayout(strip, 2, spec, 0);
|
||||
CHECK(clampTabScroll(123, fits) == 0); // no overflow -> everything clamps to 0
|
||||
}
|
||||
|
||||
// --- Hit-testing -------------------------------------------------------------
|
||||
|
||||
// No overflow: a point in a tab returns that tab; the gap-free tiling means every
|
||||
// x in the strip band lands on some tab.
|
||||
static void testHitFitStrip() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
CHECK((hitTestTabStrip(10, 12, strip, 3, spec, 0) == TabHit{TabHitKind::Tab, 0}));
|
||||
CHECK((hitTestTabStrip(100, 12, strip, 3, spec, 0) == TabHit{TabHitKind::Tab, 1}));
|
||||
CHECK((hitTestTabStrip(250, 12, strip, 3, spec, 0) == TabHit{TabHitKind::Tab, 2}));
|
||||
// Outside the band: above, below, left, right all miss.
|
||||
CHECK((hitTestTabStrip(10, -1, strip, 3, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
CHECK((hitTestTabStrip(10, 24, strip, 3, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
CHECK((hitTestTabStrip(-1, 12, strip, 3, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
CHECK((hitTestTabStrip(300, 12, strip, 3, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
}
|
||||
|
||||
// Overflow: the reserved chevron bands hit-test to the scroll affordances and take
|
||||
// precedence over any tab that would otherwise sit there.
|
||||
static void testHitChevrons() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
// Left chevron band [0,20).
|
||||
CHECK((hitTestTabStrip(5, 12, strip, 10, spec, 0) ==
|
||||
TabHit{TabHitKind::ScrollLeft, -1}));
|
||||
CHECK((hitTestTabStrip(19, 12, strip, 10, spec, 0) ==
|
||||
TabHit{TabHitKind::ScrollLeft, -1}));
|
||||
// Right chevron band [280,300).
|
||||
CHECK((hitTestTabStrip(280, 12, strip, 10, spec, 0) ==
|
||||
TabHit{TabHitKind::ScrollRight, -1}));
|
||||
CHECK((hitTestTabStrip(299, 12, strip, 10, spec, 0) ==
|
||||
TabHit{TabHitKind::ScrollRight, -1}));
|
||||
// Just inside the track (x=20) is the first tab, not the left chevron.
|
||||
CHECK((hitTestTabStrip(20, 12, strip, 10, spec, 0) == TabHit{TabHitKind::Tab, 0}));
|
||||
}
|
||||
|
||||
// A hit in the track matches the drawn (clipped) rects; a point in track dead space
|
||||
// (no visible tab under it) is None. With overflow at scroll 0 the visible tabs are
|
||||
// 0,1,2 (2 clipped to [212,280)); everything in [20,280) is covered here, so we test
|
||||
// dead space by scrolling so a tab boundary leaves no gap — instead assert the hit
|
||||
// agrees with computeTabRects for a mid-scroll offset.
|
||||
static void testHitMatchesRectsMidScroll() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
const int offset = 150;
|
||||
auto rects = computeTabRects(strip, 10, spec, offset);
|
||||
CHECK(!rects.empty());
|
||||
for (const TabRect& r : rects) {
|
||||
// A point at the rect's left edge and one just inside its right edge both
|
||||
// resolve to this tab (half-open bounds).
|
||||
CHECK((hitTestTabStrip(r.x, 12, strip, 10, spec, offset) ==
|
||||
TabHit{TabHitKind::Tab, r.index}));
|
||||
CHECK((hitTestTabStrip(r.x + r.width - 1, 12, strip, 10, spec, offset) ==
|
||||
TabHit{TabHitKind::Tab, r.index}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Degenerate inputs -------------------------------------------------------
|
||||
|
||||
static void testDegenerate() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
CHECK(computeTabRects(strip, 0, spec, 0).empty());
|
||||
CHECK((hitTestTabStrip(10, 12, strip, 0, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
|
||||
TabStripRect empty{0, 0, 0, 24};
|
||||
CHECK(computeTabRects(empty, 3, spec, 0).empty());
|
||||
TabStripLayout layout = computeTabStripLayout(empty, 3, spec, 0);
|
||||
CHECK(!layout.overflow);
|
||||
CHECK(layout.maxScroll == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFitsNoOverflow();
|
||||
testFitStripIgnoresScroll();
|
||||
testOverflowReservesChevrons();
|
||||
testOverflowScrollZeroClipsRight();
|
||||
testScrollToEndSurfacesLastTab();
|
||||
testClampScroll();
|
||||
testHitFitStrip();
|
||||
testHitChevrons();
|
||||
testHitMatchesRectsMidScroll();
|
||||
testDegenerate();
|
||||
|
||||
if (g_fail == 0) std::printf("tab_strip: all tests passed\n");
|
||||
else std::printf("tab_strip: %d FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user