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
+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