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:
2026-07-25 14:37:57 -04:00
parent 88af39e036
commit a67a2f9479
10 changed files with 1769 additions and 457 deletions
+9 -8
View File
@@ -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;
+43
View File
@@ -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;
}
+1109 -447
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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
View File
@@ -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; }
+112
View File
@@ -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
View File
@@ -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 ~812
// 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