Cut core/ui and core/audio comment bloat ~60% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:49:02 -04:00
parent 1f24c4b095
commit 3d3415f943
29 changed files with 527 additions and 1225 deletions
+13 -28
View File
@@ -5,14 +5,11 @@
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
// peaks implementation. // peaks — pure implementation. See peaks.h.
// //
// One linear pass per channel. The frame->bin partition is computed with integer // One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the // any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount)
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That // — earlier bins absorb the remainder, no rounding drift, no dropped tail.
// span formula distributes the remainder deterministically (earlier bins get the
// extra frames) with no rounding drift and no dropped tail — the last bin's end is
// always exactly frameCount.
namespace reasampler::audio { namespace reasampler::audio {
@@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t binCount) { std::size_t binCount) {
Envelope envelope(channelCount); Envelope envelope(channelCount);
if (channelCount == 0) { if (channelCount == 0) {
return envelope; // no channels -> no envelopes return envelope;
} }
// Never read past what the buffer actually holds, even if the caller's // Never read past what the buffer actually holds, even if frameCount overstates it.
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
const std::size_t availableFrames = interleaved.size() / channelCount; const std::size_t availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames); const std::size_t frames = std::min(frameCount, availableFrames);
@@ -35,14 +31,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0} bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
for (std::size_t b = 0; b < binCount; ++b) { for (std::size_t b = 0; b < binCount; ++b) {
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount). // Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is // unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB.
// caller-controlled and unbounded, so when b >= SIZE_MAX/frames either
// multiplication could wrap. Any such bin is unreachable in practice
// (allocating that many MinMax entries would OOM first), but we guard
// explicitly to eliminate UB.
if (frames > 0 && b >= SIZE_MAX / frames) { if (frames > 0 && b >= SIZE_MAX / frames) {
continue; // b*frames or (b+1)*frames would overflow; span is empty continue;
} }
const std::size_t begin = (b * frames) / binCount; const std::size_t begin = (b * frames) / binCount;
const std::size_t end = ((b + 1) * frames) / binCount; const std::size_t end = ((b + 1) * frames) / binCount;
@@ -69,21 +61,17 @@ MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) {
const int nbins = static_cast<int>(bins.size()); const int nbins = static_cast<int>(bins.size());
if (columnCount <= 0 || nbins == 0) return MinMax{}; if (columnCount <= 0 || nbins == 0) return MinMax{};
// Clamp col to [0, columnCount-1].
if (col < 0) col = 0; if (col < 0) col = 0;
if (col >= columnCount) col = columnCount - 1; if (col >= columnCount) col = columnCount - 1;
// Half-open bin range for this column, mirroring computeEnvelope's exact partition. // Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope // products: col*nbins can exceed int range for a large oversampled envelope.
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount; const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
const std::int64_t end64 = const std::int64_t end64 =
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount; (static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
// col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins.
const int colBinBegin = static_cast<int>(begin64); const int colBinBegin = static_cast<int>(begin64);
// When the column spans no full bin (more columns than bins), use the enclosing bin // When the column spans no full bin (more columns than bins), use the enclosing bin.
// so no column is left empty.
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1; const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins; const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
@@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
AudioSample linearThreshold) { AudioSample linearThreshold) {
if (channelCount == 0) return kNoFrameAboveThreshold; if (channelCount == 0) return kNoFrameAboveThreshold;
// Clamp to what the buffer actually holds — a caller frameCount that overstates
// the buffer must never read past the end (mirror of computeEnvelope's guard).
const std::size_t availableFrames = interleaved.size() / channelCount; const std::size_t availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames); const std::size_t frames = std::min(frameCount, availableFrames);
if (frames == 0) return kNoFrameAboveThreshold; if (frames == 0) return kNoFrameAboveThreshold;
// Scan backward: the first frame (from the end) whose loudest channel exceeds the // Scan backward; `f` runs frames..1 so `f-1` never wraps.
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
for (std::size_t f = frames; f > 0; --f) { for (std::size_t f = frames; f > 0; --f) {
const std::size_t frame = f - 1; const std::size_t frame = f - 1;
const std::size_t base = frame * channelCount; const std::size_t base = frame * channelCount;
+42 -76
View File
@@ -1,32 +1,20 @@
#pragma once #pragma once
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved // peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute
// PCM. We compute our own thumbnails from the captured file rather than depending // our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this
// on REAPER's peak API: we own the file format, so this is simpler, testable, and // is simpler, testable, and dependency-free.
// dependency-free. A future bank panel (M5) calls this at whatever bin resolution
// the panel width dictates and draws one min/max envelope per channel.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
#include <cstddef> #include <cstddef>
#include <vector> #include <vector>
namespace reasampler::audio { namespace reasampler::audio {
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer // REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so // lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's
// peaks consumes that directly with no lossy conversion. If a capture ever lands // metadata struct of the same short name.
// as a different depth, the caller converts to float at the boundary — the
// thumbnail core stays single-typed.
//
// NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's
// metadata struct. A `using Sample = float` here would collide at namespace scope
// wherever both headers are visible (the bank_panel module includes both). The
// audio-domain name also reads more precisely — this is one PCM sample value.
using AudioSample = float; using AudioSample = float;
// One bin of a channel's envelope: the extremes of every sample that fell in it. // One bin's extremes across the samples that fell in it. min <= max always; an empty bin
// min <= max always. For an empty bin (more bins than frames), both are 0. // (more bins than frames) is {0, 0}.
struct MinMax { struct MinMax {
AudioSample min = 0.0f; AudioSample min = 0.0f;
AudioSample max = 0.0f; AudioSample max = 0.0f;
@@ -37,83 +25,61 @@ struct MinMax {
// One channel's envelope: exactly `binCount` bins, in time order. // One channel's envelope: exactly `binCount` bins, in time order.
using ChannelEnvelope = std::vector<MinMax>; using ChannelEnvelope = std::vector<MinMax>;
// Per-channel envelopes: outer index is channel (channelCount entries, order // Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never
// preserved — never mixed or folded), inner is that channel's bins. // mixed or folded), inner is that channel's bins.
using Envelope = std::vector<ChannelEnvelope>; using Envelope = std::vector<ChannelEnvelope>;
// Computes a per-channel min/max envelope from interleaved PCM. // Computes a per-channel min/max envelope from interleaved PCM.
// //
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. // interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be
// Size must be >= frameCount * channelCount; extra is ignored. // >= frameCount * channelCount; extra is ignored.
// channelCount channels per frame (the stride). Each channel is enveloped // channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no
// INDEPENDENTLY — no averaging, no stereo fold (precision // averaging, no stereo fold (channel count is preserved end to end).
// invariant: channel count preserved).
// frameCount frames (samples-per-channel) to consider. // frameCount frames (samples-per-channel) to consider.
// binCount requested bins per channel. Honored exactly for any frameCount. // binCount requested bins per channel. Honored exactly for any frameCount.
// //
// Frame->bin partition: frames are split into `binCount` contiguous spans as // Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible;
// evenly as possible; when frameCount does not divide evenly, the remainder is // when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the
// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the // earliest bins, so the tail is never dropped and no bin reads out of bounds.
// tail is never dropped and no bin reads out of bounds. When binCount > frameCount
// the trailing empty bins are {0, 0}.
// //
// Defined behavior for degenerate input (no UB, no throw): // Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel;
// binCount == 0 -> per channel: an empty bin vector. // channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}.
// channelCount == 0 -> an empty envelope (no channels).
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved, Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t channelCount, std::size_t channelCount,
std::size_t frameCount, std::size_t frameCount,
std::size_t binCount); std::size_t binCount);
// The merged min/max for display column `col` (0-based, of `columnCount` total columns) // Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects // ChannelEnvelope the true extremes of every bin projecting to that column. This is the
// to that column. This is the display-side collapse of an envelope computed at HIGHER // display-side collapse when the envelope was computed at a higher resolution than the drawn
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a // width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9})
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then // renders as one gap-free span instead of two separated dots.
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
// //
// Bin->column mapping mirrors computeEnvelope's half-open partition: // Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins
// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount). // [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns
// When that range is empty (more columns than bins), the enclosing bin // than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty()
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is // returns {0, 0}; col is clamped to [0, columnCount-1].
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
// [0, columnCount-1]. Pure.
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col); MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range // Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous: // since no real frame index can reach it.
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
inline constexpr std::size_t kNoFrameAboveThreshold = inline constexpr std::size_t kNoFrameAboveThreshold =
static_cast<std::size_t>(-1); static_cast<std::size_t>(-1);
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max // Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across
// absolute value across all channels of that frame — NO stereo fold, just the // all channels of that frame — no stereo fold) exceeds `linearThreshold`. Returns
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index. // kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
// //
// This is the boundary primitive behind the realtime tail's decay-scan trim // This is the boundary primitive behind the realtime tail's decay-scan trim (see
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is // docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last
// scanned back from the end for the last frame still above -72 dB, and the file is // frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from
// truncated one frame past it. Deliberately a separate primitive from // computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), // "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented // question is a worse fit.
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
// //
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. // linearThreshold a LINEAR amplitude ratio (e.g. the -72 dB ratio from
// Must hold >= frameCount * channelCount; extra is ignored, and a // render_settings::autoTrimEndRatio), NOT dB. A frame counts as above when
// short buffer is clamped to what it actually holds (no OOB read). // its peak is STRICTLY greater than this.
// channelCount channels per frame (the stride). The per-frame test is the max
// |sample| over these channels — the frame is "above" if its
// loudest channel is above the threshold.
// frameCount frames to consider (the scan starts at the last of these).
// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the
// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB.
// A frame counts as above when its peak is STRICTLY > this.
//
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved, std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
std::size_t channelCount, std::size_t channelCount,
std::size_t frameCount, std::size_t frameCount,
+13 -25
View File
@@ -1,4 +1,4 @@
// action_bar — pure implementation. See action_bar.h. NO REAPER / SWELL / LICE / vendor. // action_bar — pure implementation. See action_bar.h.
#include "core/ui/action_bar.h" #include "core/ui/action_bar.h"
@@ -8,7 +8,6 @@ namespace reasampler::ui {
namespace { namespace {
// The total button count across all clusters (empty clusters contribute nothing).
int totalButtons(const std::vector<ClusterSpec>& clusters) { int totalButtons(const std::vector<ClusterSpec>& clusters) {
int n = 0; int n = 0;
for (const ClusterSpec& c : clusters) for (const ClusterSpec& c : clusters)
@@ -16,21 +15,16 @@ int totalButtons(const std::vector<ClusterSpec>& clusters) {
return n; return n;
} }
// Fills a slot's label rect from its box. The label spans the full button height — a single-row
// short label (L6: keybinding sub-row removed from the face; binding is in the hover tooltip).
// Insets horizontally so text clears the button edge.
void fillTextRects(ActionBarSlot& s, const ActionBarSpec& /*spec*/) { void fillTextRects(ActionBarSlot& s, const ActionBarSpec& /*spec*/) {
const int hpad = 4; // horizontal text inset inside the button const int hpad = 4;
const int innerX = s.x + hpad; const int innerX = s.x + hpad;
const int innerW = s.width - 2 * hpad; const int innerW = s.width - 2 * hpad;
if (innerW <= 0) return; // too narrow for text; leave label rect empty if (innerW <= 0) return;
s.labelX = innerX; s.labelY = s.y; s.labelW = innerW; s.labelH = s.height; s.labelX = innerX; s.labelY = s.y; s.labelW = innerW; s.labelH = s.height;
} }
// Tiles the first `visible` buttons into slots, cluster by cluster, left to right. This is the // The one placement routine; computeBarSlots and hitTestActionBar both drive it so draw and
// ONE placement routine; both computeBarSlots and hitTestActionBar drive it so draw and // hit-test can't drift apart.
// hit-test can never drift. `visible` is assumed already clamped to [0, total]. Returns the
// slots in ascending flat-index order.
std::vector<ActionBarSlot> tile(const ActionBarRect& bar, std::vector<ActionBarSlot> tile(const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec, int visible) { const ActionBarSpec& spec, int visible) {
@@ -43,21 +37,20 @@ std::vector<ActionBarSlot> tile(const ActionBarRect& bar,
if (btnH <= 0) return slots; if (btnH <= 0) return slots;
int cursorX = bar.x + spec.sidePad; int cursorX = bar.x + spec.sidePad;
int flatIndex = 0; // running flat action index across all clusters int flatIndex = 0;
int placed = 0; // buttons placed so far (stops at `visible`) int placed = 0;
bool firstClusterEmitted = false; bool firstClusterEmitted = false;
for (const ClusterSpec& c : clusters) { for (const ClusterSpec& c : clusters) {
if (c.count <= 0) continue; // skip empty clusters (no gap emitted) if (c.count <= 0) continue;
if (placed >= visible) break; if (placed >= visible) break;
// Gap BEFORE this cluster (except the first non-empty one).
if (firstClusterEmitted) cursorX += spec.clusterGap; if (firstClusterEmitted) cursorX += spec.clusterGap;
firstClusterEmitted = true; firstClusterEmitted = true;
for (int i = 0; i < c.count; ++i, ++flatIndex) { for (int i = 0; i < c.count; ++i, ++flatIndex) {
if (placed >= visible) return slots; // overflow cut — stop cleanly if (placed >= visible) return slots;
if (i > 0) cursorX += spec.buttonGap; // gap between buttons in the cluster if (i > 0) cursorX += spec.buttonGap;
ActionBarSlot s; ActionBarSlot s;
s.index = flatIndex; s.index = flatIndex;
@@ -76,9 +69,7 @@ std::vector<ActionBarSlot> tile(const ActionBarRect& bar,
return slots; return slots;
} }
// The rightmost pixel the first `visible` buttons would occupy (bar.x + sidePad based). Used by // Mirrors tile()'s advance math so fit and layout agree.
// computeBarFit to test whether a candidate visible-count fits within the bar's usable width.
// Mirrors tile()'s advance math exactly (gaps included) so fit and layout agree.
int rightEdgeFor(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters, int rightEdgeFor(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec, int visible) { const ActionBarSpec& spec, int visible) {
if (visible <= 0) return bar.x + spec.sidePad; if (visible <= 0) return bar.x + spec.sidePad;
@@ -93,7 +84,7 @@ int rightEdgeFor(const ActionBarRect& bar, const std::vector<ClusterSpec>& clust
for (int i = 0; i < c.count; ++i) { for (int i = 0; i < c.count; ++i) {
if (placed >= visible) return cursorX; if (placed >= visible) return cursorX;
if (i > 0) cursorX += spec.buttonGap; if (i > 0) cursorX += spec.buttonGap;
cursorX += spec.buttonWidth; // this button's right edge cursorX += spec.buttonWidth;
++placed; ++placed;
if (placed >= visible) return cursorX; if (placed >= visible) return cursorX;
} }
@@ -113,8 +104,6 @@ BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& c
} }
const int usableRight = bar.x + bar.width - spec.sidePad; const int usableRight = bar.x + bar.width - spec.sidePad;
// Largest prefix of buttons whose right edge stays within the usable right bound. Buttons
// never shrink; trailing ones that do not fit are the overflow (dropped whole).
int visible = 0; int visible = 0;
for (int cand = 1; cand <= total; ++cand) { for (int cand = 1; cand <= total; ++cand) {
if (rightEdgeFor(bar, clusters, spec, cand) <= usableRight) if (rightEdgeFor(bar, clusters, spec, cand) <= usableRight)
@@ -138,7 +127,6 @@ std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
int hitTestActionBar(int px, int py, const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec) { const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec) {
if (bar.height <= 0 || bar.width <= 0) return -1; if (bar.height <= 0 || bar.width <= 0) return -1;
// Reject outside the bar band first (half-open bounds match the slots).
if (px < bar.x || px >= bar.x + bar.width || if (px < bar.x || px >= bar.x + bar.width ||
py < bar.y || py >= bar.y + bar.height) py < bar.y || py >= bar.y + bar.height)
return -1; return -1;
@@ -148,7 +136,7 @@ int hitTestActionBar(int px, int py, const ActionBarRect& bar,
if (px >= s.x && px < s.x + s.width && py >= s.y && py < s.y + s.height) if (px >= s.x && px < s.x + s.width && py >= s.y && py < s.y + s.height)
return s.index; return s.index;
} }
return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss return -1;
} }
} // namespace reasampler::ui } // namespace reasampler::ui
+26 -96
View File
@@ -1,73 +1,29 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's // action_bar — layout + hit-test for the bank_panel's task-grouped toolbars: buttons cluster by
// TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a // task (Capture/Placement/Maintenance/Tagging/Switching); on a narrow panel, whole trailing
// thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact // buttons drop rather than shrink or clip. The destructive Prune button lives separately in
// bar of clusters, each button carrying a label sub-rect spanning // prune_button, kept out of this cluster on purpose.
// its full height — a single-row short label (L6: the keybinding sub-row was on the button face
// through L5; L6 moves it to the hover tooltip instead). The bar degrades gracefully on a narrow
// panel by dropping WHOLE trailing buttons (never clipping) so the frequent leading cluster
// survives.
//
// L4 re-homes the inventory across TWO toolbars, BOTH driven by this one module: a TOP toolbar
// (Capture + Placement — the two acts the tool exists for) and a BOTTOM toolbar (the Design-View
// verbs, Tagging then Switching). The tiling is cluster-agnostic — it walks the caller's
// ClusterSpec list in order — so the same computeBarSlots / hitTestActionBar serve both bars;
// only the cluster membership and the band rect differ per toolbar.
//
// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): the panel shell owns the SWELL
// window, the L1-kit draws, and the NamedCommandLookup/Main_OnCommand dispatch — all
// DAW-verified. What is NOT DAW-bound — how the clusters tile the bar, where each button and
// its label sub-rect sit, and which button a click hits — lives HERE, unit-tested outside the
// DAW. Mirror of mode_switch / prune_button.
//
// NAME NOTE (brief §name-collision): ButtonRect / ButtonStripRect / ActionButtonRect /
// SegmentRect / CellRect / FooterRect / KitButtonBox are already owned in this namespace, so
// this module's types are ActionBarRect / ActionBarSlot / ActionCluster — grep-checked free
// before minting. They are a distinct concept (a task-grouped multi-cluster bar with text
// sub-rects), so the separate names are correct, not merely non-colliding.
//
// SCOPE: the destructive PRUNE button is NOT in this bar — it stays set-apart in the footer,
// warn-marked, owned by prune_button (L2 keeps prune deliberately away from the frequent
// action cluster). This module lays out only the non-destructive capture/placement/maintenance
// actions.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include <vector> #include <vector>
namespace reasampler::ui { namespace reasampler::ui {
// The task cluster a button belongs to (the L2 "group by task" mandate). The order here is // Task cluster a button belongs to. Cluster order is caller-supplied via ClusterSpec, not fixed
// NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum // here; a slot just carries which cluster it landed in.
// only names the groups so a slot can carry (and a test/shell can assert) its membership.
//
// L4 split the panel's buttons across TWO toolbars, each an action_bar instance:
// * the TOP toolbar draws Capture + Placement (the two acts the tool exists for);
// * the BOTTOM toolbar draws the Design-View verbs, grouped Tagging then Switching.
// Both toolbars share this ONE pure layout module (the tiling is cluster-agnostic — it walks
// the caller's ClusterSpec list in order), so a cluster value belongs to whichever toolbar
// the shell places it in; nothing here couples a cluster to a specific bar.
enum class ActionCluster { enum class ActionCluster {
Capture, // capture item / track / realtime / batch — top toolbar, primary gesture Capture,
Placement, // insert at cursor / insert-conform — top toolbar, placing a sample Placement,
Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions Maintenance,
Tagging, // tag / untag selected tracks for the active mode — bottom toolbar (L4) Tagging,
Switching, // activate Arrange / Design, toggle mode, show-both — bottom toolbar (L4) Switching,
}; };
// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the using ActionBarRect = Rect;
// top-left corner; width/height are the bar extents. The panel reserves this as a fixed-height
// band (its own judgment where — above the tail footer, below the split body).
using ActionBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// One visible button's placement within the bar, top-left origin. `index` is the button's // One visible button's placement, top-left origin. `index` is its position in the caller's flat
// position in the caller's flat action list (the caller supplies actions in cluster order, so // action list (cluster order), so index also selects the action to fire on a hit. Only buttons
// index also selects the action to fire on a hit). `cluster` is the task group it was laid out // that fit get a slot — overflow is dropped whole, never clipped.
// under (surfaced so a test can assert the grouping is structural, and the shell can tint a
// cluster). `box` is the whole button rect; `labelBox` is the text area inset horizontally so
// text clears the button edge. Only VISIBLE buttons get a slot — a button that does not fit is
// omitted, never returned clipped, so every slot is fully drawable.
struct ActionBarSlot { struct ActionBarSlot {
int index = 0; int index = 0;
ActionCluster cluster = ActionCluster::Capture; ActionCluster cluster = ActionCluster::Capture;
@@ -75,9 +31,7 @@ struct ActionBarSlot {
int y = 0; int y = 0;
int width = 0; int width = 0;
int height = 0; int height = 0;
// Label rect (absolute, top-left origin), inside `box`. The label spans the full button // Label sub-rect, full button height, horizontally inset so text clears the edge.
// height — a single-row short label only (L6: keybinding sub-row removed from the face;
// binding is surfaced in the hover tooltip instead).
int labelX = 0, labelY = 0, labelW = 0, labelH = 0; int labelX = 0, labelY = 0, labelW = 0, labelH = 0;
bool operator==(const ActionBarSlot& o) const { bool operator==(const ActionBarSlot& o) const {
@@ -88,26 +42,14 @@ struct ActionBarSlot {
} }
}; };
// One cluster's button count, in the caller's flat action-list order. The caller passes these // One cluster's button count, in the order the caller wants it drawn. count == 0 skips the
// in the left-to-right order it wants them drawn (top toolbar: Capture then Placement; bottom // cluster (no gap emitted). Flat action indices run cluster-by-cluster in this order.
// toolbar: Tagging then Switching); a cluster with count 0 is skipped (no gap emitted for it).
// The flat action index a slot carries is the running sum across clusters (cluster 0's buttons
// are indices [0, counts[0]), etc.), so the shell's flat action table lines up with the slots
// by index.
struct ClusterSpec { struct ClusterSpec {
ActionCluster cluster = ActionCluster::Capture; ActionCluster cluster = ActionCluster::Capture;
int count = 0; int count = 0;
}; };
// Layout inputs for the bar, in pixels. Defaults are the bank_panel action-bar metrics; the // Layout inputs, in pixels; defaults are the bank_panel action-bar metrics.
// shell passes its own so draw and hit-test share ONE source of truth.
// * buttonWidth — each button's fixed width (buttons never render narrower; overflow drops
// whole trailing buttons instead of shrinking below this).
// * buttonGap — horizontal gap between buttons WITHIN a cluster.
// * clusterGap — horizontal gap between adjacent clusters (wider than buttonGap so the
// task grouping reads visually; the 8px-grid density decision).
// * sidePad — left/right inset from the bar edges to the first/last button.
// * verticalInset — top/bottom gap inside the bar (buttons read as raised, not full-bleed).
struct ActionBarSpec { struct ActionBarSpec {
int buttonWidth = 108; int buttonWidth = 108;
int buttonGap = 4; int buttonGap = 4;
@@ -116,35 +58,23 @@ struct ActionBarSpec {
int verticalInset = 3; int verticalInset = 3;
}; };
// How many buttons (from the front, cluster by cluster) fit the bar at `spec.buttonWidth`. // How many buttons (from the front) fit at spec.buttonWidth. Split out so the shell can size an
// Split from slot tiling so the shell can size an overflow affordance / count without // overflow affordance without re-deriving it. A bar too narrow for even one button yields 0.
// re-deriving it. Trailing buttons that do not fit are the overflow (dropped whole). A
// non-positive bar width, or a bar too narrow for even one button, yields 0. Clamps to
// [0, total-button-count].
struct BarFit { struct BarFit {
int visibleCount = 0; // buttons that fit (laid out), counted from the front int visibleCount = 0;
int hiddenCount = 0; // total - visibleCount (the overflow, dropped whole) int hiddenCount = 0;
}; };
BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters, BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec); const ActionBarSpec& spec);
// Lays out the VISIBLE buttons (per computeBarFit) left-to-right in cluster order: buttons // Lays out the visible buttons (per computeBarFit) left-to-right in cluster order.
// pack at buttonWidth with buttonGap inside a cluster and clusterGap between clusters, starting
// at bar.x + sidePad. Each slot carries its flat action index, its cluster, its box, and the
// label sub-rect (full-height single row). Empty clusters emit no gap. Returns exactly
// visibleCount slots in ascending index order. A degenerate bar (width/height <= 0), an empty
// cluster list, or a non-positive buttonWidth yields empty.
std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar, std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec); const ActionBarSpec& spec);
// The flat action index the point (px, py) (SWELL/LICE top-left client coords) lands on, or -1 // Flat action index under (px, py), or -1 for a miss (outside the bar, in a gap, or past the
// for a miss: outside the bar band, in an inter-button / inter-cluster gap, or past the last // last visible button). Gaps are real dead-zones here, not resolved to the nearest button.
// visible button (the narrow-panel overflow dead-zone — a harmless no-op the shell ignores).
// Half-open bounds [x, x+width) x [y, y+height) match computeBarSlots so no pixel is double-
// claimed and the hit maps to the button drawn there. Unlike an equal-tiled strip, the bar has
// real gaps, so a gap point is a clean miss (not the nearest button).
int hitTestActionBar(int px, int py, const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec); const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec);
+13 -42
View File
@@ -1,4 +1,4 @@
// bank_grid — pure implementation. See bank_grid.h. NO REAPER / SWELL / vendor. // bank_grid — pure implementation. See bank_grid.h.
#include "core/ui/bank_grid.h" #include "core/ui/bank_grid.h"
@@ -9,8 +9,7 @@ namespace reasampler::ui {
namespace { namespace {
// Builds a sorted, unique ascending index vector for the inclusive range [a, b] // Sorted, unique ascending index vector for the inclusive range [a, b] (order-agnostic in a/b).
// (order-agnostic in a/b). Both ends assumed already in-range by the caller.
std::vector<int> rangeIndices(int a, int b) { std::vector<int> rangeIndices(int a, int b) {
if (a > b) std::swap(a, b); if (a > b) std::swap(a, b);
std::vector<int> out; std::vector<int> out;
@@ -19,8 +18,6 @@ std::vector<int> rangeIndices(int a, int b) {
return out; 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 singleSelection(int index) {
Selection s; Selection s;
s.indices = {index}; s.indices = {index};
@@ -32,11 +29,9 @@ Selection singleSelection(int index) {
} // namespace } // 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).
// gap + n*(cellWidth + gap). Solve for the largest n that fits panelWidth,
// clamped to at least 1 so a too-narrow panel still shows a (clipped) column.
const int cell = spec.cellWidth + spec.gap; const int cell = spec.cellWidth + spec.gap;
if (cell <= 0) return 1; // degenerate spec — one column, avoid divide-by-zero if (cell <= 0) return 1;
const int usable = panelWidth - spec.gap; const int usable = panelWidth - spec.gap;
if (usable < spec.cellWidth) return 1; if (usable < spec.cellWidth) return 1;
const int cols = usable / cell; const int cols = usable / cell;
@@ -68,15 +63,12 @@ std::vector<CellRect> computeCellRects(int itemCount,
int contentHeight(int itemCount, int panelWidth, const GridSpec& spec) { int contentHeight(int itemCount, int panelWidth, const GridSpec& spec) {
if (itemCount <= 0) return 0; if (itemCount <= 0) return 0;
const int cols = columnsForWidth(panelWidth, spec); const int cols = columnsForWidth(panelWidth, spec);
// Ceil-divide item count by columns to get the row count (partial last row const int rows = (itemCount + cols - 1) / cols; // ceil-divide
// still occupies a full row of height).
const int rows = (itemCount + cols - 1) / cols;
return spec.gap + rows * (spec.cellHeight + spec.gap); return spec.gap + rows * (spec.cellHeight + spec.gap);
} }
std::string thumbnailKeyString(const ThumbnailKey& key) { std::string thumbnailKeyString(const ThumbnailKey& key) {
// Length-prefix the sampleId so a delimiter byte inside an id cannot forge a // Length-prefix sampleId so a delimiter byte inside it can't forge a collision.
// collision with a different (id, width, generation) triple.
std::string s; std::string s;
s.reserve(key.sampleId.size() + 32); s.reserve(key.sampleId.size() + 32);
s += std::to_string(key.sampleId.size()); s += std::to_string(key.sampleId.size());
@@ -94,7 +86,6 @@ std::string thumbnailKeyString(const ThumbnailKey& key) {
int hitTestCell(int px, int py, const std::vector<CellRect>& rects) { int hitTestCell(int px, int py, const std::vector<CellRect>& rects) {
for (std::size_t i = 0; i < rects.size(); ++i) { for (std::size_t i = 0; i < rects.size(); ++i) {
const CellRect& r = rects[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 && if (px >= r.x && px < r.x + r.width &&
py >= r.y && py < r.y + r.height) py >= r.y && py < r.y + r.height)
return static_cast<int>(i); return static_cast<int>(i);
@@ -110,15 +101,14 @@ Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
int itemCount) { int itemCount) {
if (itemCount <= 0 || index < 0 || index >= itemCount) return current; if (itemCount <= 0 || index < 0 || index >= itemCount) return current;
// Shift takes precedence over ctrl (documented): range-select from the anchor.
if (shift) { if (shift) {
const int anchor = current.anchor >= 0 && current.anchor < itemCount const int anchor = current.anchor >= 0 && current.anchor < itemCount
? current.anchor ? current.anchor
: index; // no valid anchor -> seed at the click : index;
Selection s; Selection s;
s.indices = rangeIndices(anchor, index); s.indices = rangeIndices(anchor, index);
s.focus = index; s.focus = index;
s.anchor = anchor; // anchor unchanged across a shift-range s.anchor = anchor;
return s; return s;
} }
@@ -126,15 +116,14 @@ Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
Selection s = current; Selection s = current;
auto it = std::lower_bound(s.indices.begin(), s.indices.end(), index); auto it = std::lower_bound(s.indices.begin(), s.indices.end(), index);
if (it != s.indices.end() && *it == index) if (it != s.indices.end() && *it == index)
s.indices.erase(it); // toggle OUT s.indices.erase(it);
else else
s.indices.insert(it, index); // toggle IN (keeps sorted order) s.indices.insert(it, index);
s.focus = index; s.focus = index;
s.anchor = index; // ctrl-click reseeds the range origin s.anchor = index;
return s; return s;
} }
// Plain click: sole selection.
return singleSelection(index); return singleSelection(index);
} }
@@ -143,8 +132,7 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
if (itemCount <= 0) return current; if (itemCount <= 0) return current;
if (cols < 1) cols = 1; if (cols < 1) cols = 1;
// A fresh panel (no focus): the first key press focuses cell 0 without moving, // Fresh panel: 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 (current.focus < 0 || current.focus >= itemCount) {
if (shift) { if (shift) {
Selection s; Selection s;
@@ -160,22 +148,15 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
int to = from; int to = from;
switch (key) { switch (key) {
case NavKey::Left: case NavKey::Left:
// Move one; clamp at cell 0 (stay put on the first cell).
if (from > 0) to = from - 1; if (from > 0) to = from - 1;
break; break;
case NavKey::Right: case NavKey::Right:
// Move one; clamp at the last cell (stay put on the last cell).
if (from < itemCount - 1) to = from + 1; if (from < itemCount - 1) to = from + 1;
break; break;
case NavKey::Up: case NavKey::Up:
// Move up a row; if that leaves the grid (top row) stay put.
if (from - cols >= 0) to = from - cols; if (from - cols >= 0) to = from - cols;
break; break;
case NavKey::Down: { 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; const int below = from + cols;
if (below < itemCount) if (below < itemCount)
to = below; to = below;
@@ -189,7 +170,6 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
if (!shift) return singleSelection(to); 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 const int anchor = current.anchor >= 0 && current.anchor < itemCount
? current.anchor ? current.anchor
: from; : from;
@@ -203,23 +183,14 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
float compressAmplitudeForDisplay(float linear) { float compressAmplitudeForDisplay(float linear) {
const float mag = linear < 0.0f ? -linear : linear; const float mag = linear < 0.0f ? -linear : linear;
// The linear magnitude at the floor threshold: 10^(kDisplayFloorDb/20). // std::pow isn't constexpr pre-C++20; derive at runtime, cheap since it's once per bin.
// Any magnitude at or below this maps to display fraction 0.
// Computed once as a constant expression; std::pow is constexpr in C++20 but
// not C++17, so derive it via the floor definition directly at runtime — it is
// only called once per bin, and the branch-free math is cheap.
const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f); const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f);
if (mag <= floorMag) return 0.0f; // below floor (and guards log10(0)) if (mag <= floorMag) return 0.0f; // below floor (and guards log10(0))
// dB in [kDisplayFloorDb, 0] for magnitude in [floorMag, 1].
const float db = 20.0f * std::log10(mag); const float db = 20.0f * std::log10(mag);
// Normalize to [0, 1]: 0 at kDisplayFloorDb, 1 at 0 dB.
const float fraction = (db - kDisplayFloorDb) / (0.0f - kDisplayFloorDb); const float fraction = (db - kDisplayFloorDb) / (0.0f - kDisplayFloorDb);
// Clamp to [0, 1] so floating-point overshoot on |linear| > 1.0 stays bounded,
// then re-apply the original sign.
const float clamped = fraction < 0.0f ? 0.0f : (fraction > 1.0f ? 1.0f : fraction); const float clamped = fraction < 0.0f ? 0.0f : (fraction > 1.0f ? 1.0f : fraction);
return linear < 0.0f ? -clamped : clamped; return linear < 0.0f ? -clamped : clamped;
} }
+49 -109
View File
@@ -1,14 +1,7 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// bank_grid — the REAPER-free layout math and cache-key logic behind the docked // bank_grid — layout math, hit-test, selection, and keyboard nav for the docked bank_panel grid,
// bank_panel (M5, Wave A). The panel shell (shell/panel/) owns the SWELL window, // plus its thumbnail cache-key. The panel shell owns SWELL/LICE/PCM; this is the DAW-free half.
// LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What
// is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and
// the key that identifies a cached thumbnail — lives here so it is unit-tested
// outside the DAW (CLAUDE.md §load-bearing split).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER.
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -17,50 +10,34 @@
namespace reasampler::ui { namespace reasampler::ui {
// A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE // One cell's pixel rect, top-left origin. Draw bounds for one sample's thumbnail.
// convention). (x, y) is the top-left corner; width/height are the cell extents. using CellRect = Rect;
// These are the draw bounds for one sample's thumbnail; the panel draws its
// waveform envelope inside this rect (minus any internal padding it applies).
using CellRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the // cellWidth/cellHeight are the target cell size; layout fits as many whole columns as the panel
// TARGET cell size; the layout fits as many whole columns as the panel width // width allows (>= 1) and wraps rows as needed. gap is the spacing between cells and the margin.
// allows (>= 1) and wraps to as many rows as N requires. gap is the pixel spacing
// between adjacent cells (and the outer margin), so cells never touch.
struct GridSpec { struct GridSpec {
int cellWidth = 120; int cellWidth = 120;
int cellHeight = 72; int cellHeight = 72;
int gap = 8; int gap = 8;
}; };
// Computes the number of columns that fit in a panel of the given pixel width for // Columns that fit a panel of the given width. Always >= 1 (a too-narrow panel still shows one
// the spec. Always >= 1 (a panel narrower than one cell still shows one column, // clipped column).
// clipped by the window). Pure arithmetic — the panel passes its live client
// width here and to computeCellRects.
int columnsForWidth(int panelWidth, const GridSpec& spec); int columnsForWidth(int panelWidth, const GridSpec& spec);
// Tiles `itemCount` cells left-to-right, top-to-bottom into a panel of the given // Tiles itemCount cells left-to-right, top-to-bottom. Returns exactly itemCount rects in item
// pixel width, honoring the spec's cell size and gap. Returns exactly itemCount // order. A partial last row is left-aligned, not centered or stretched. itemCount == 0 -> empty.
// rects in item order (rect i is sample i). A partial last row is left-aligned
// and simply shorter — no centering, no stretching. itemCount == 0 -> empty.
// panelWidth is used only to derive the column count; the returned rects may
// extend below any fixed viewport height (the panel scrolls/clips in Wave B).
std::vector<CellRect> computeCellRects(int itemCount, std::vector<CellRect> computeCellRects(int itemCount,
int panelWidth, int panelWidth,
const GridSpec& spec); const GridSpec& spec);
// The total pixel height the grid occupies for itemCount cells at the given panel // Total pixel height the grid occupies (top margin + rows*cellHeight + inter-row gaps + bottom
// width and spec (top margin + rows*cellHeight + inter-row gaps + bottom margin). // margin); 0 when itemCount == 0.
// 0 when itemCount == 0. The panel uses this to know its full content height
// (scroll extent in Wave B; for Wave A it sizes the empty-vs-populated decision).
int contentHeight(int itemCount, int panelWidth, const GridSpec& spec); int contentHeight(int itemCount, int panelWidth, const GridSpec& spec);
// Identifies one cached thumbnail. A cached envelope is valid only while the // Identifies one cached thumbnail. Valid only while sample identity, the draw width it was
// sample's identity, the draw width it was computed at, and the bank generation // computed at (the envelope has exactly `width` bins per channel), and bank generation all match;
// it was computed under all match. Width is part of the key because the envelope // generation bump invalidates every cached entry without diffing.
// has exactly `width` bins per channel (peaks::computeEnvelope is width-driven);
// a resized panel needs a fresh envelope. Generation lets the panel invalidate
// every entry when the bank changes (capture / project load) without diffing.
struct ThumbnailKey { struct ThumbnailKey {
std::string sampleId; std::string sampleId;
int width = 0; int width = 0;
@@ -72,35 +49,22 @@ struct ThumbnailKey {
} }
}; };
// A stable string form of the key, suitable as a map key. Deterministic: the same // Stable string form of the key for use as a map key. sampleId is length-prefixed so a delimiter
// key always yields the same string, distinct keys always differ (the sampleId is // byte inside an id can't forge a collision.
// 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 -------------- // --- Interaction: 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 (shell/panel/) 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. // Index of the first rect containing (px, py), or -1 for a miss (gap, margin, below last row).
// Returns the index of the FIRST rect that contains the point, or -1 for a miss // Half-open bounds so adjacent rects never both claim a pixel.
// (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); 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 // Panel selection state. `indices` is sorted unique ascending (deterministic for tests and
// ascending vector (deterministic for tests and for highlight iteration). `focus` // highlight order). `focus` is the caret cell (audition/extend target), -1 when none. `anchor` is
// is the cell the caret sits on — the audition/extend target — or -1 when nothing // the fixed end a shift-range extends from, -1 when none. Empty selection: focus == anchor == -1.
// 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): // Invariants upheld by the mutators below: indices sorted/unique; every index (and focus/anchor
// * indices is sorted ascending with no duplicates; // when >= 0) is in [0, itemCount); focus, when >= 0, is a member of indices.
// * every index (and focus/anchor when >= 0) is in [0, itemCount);
// * focus, when >= 0, is a member of indices.
struct Selection { struct Selection {
std::vector<int> indices; std::vector<int> indices;
int focus = -1; int focus = -1;
@@ -113,66 +77,42 @@ struct Selection {
bool empty() const { return indices.empty(); } bool empty() const { return indices.empty(); }
}; };
// Applies a mouse click on cell `index` to `current`, returning the new selection. // Applies a click on cell `index` to `current`. Modifier semantics (file-manager convention):
// Modifier semantics (standard multi-select, matching file-manager conventions): // * plain: select only `index`; focus = anchor = index.
// * plain (no modifier): select ONLY `index`; focus = anchor = index. // * ctrl: toggle `index` in/out; focus = index; anchor reseeds to index either way.
// * ctrl: TOGGLE `index` in/out of the set; focus = index. Anchor moves to // * shift: select the inclusive range [anchor, index]; focus = index, anchor unchanged.
// index on add, and to index on remove too (a ctrl-click reseeds the // No prior anchor behaves like a plain click.
// range origin at the clicked cell). If the toggle empties the set, // ctrl+shift together: shift wins (range select). index out of range or itemCount <= 0: no-op.
// 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, Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
int itemCount); int itemCount);
// A directional key for keyboard navigation. REAPER-free (the shell maps VK_* to // Directional key for nav; Enter/Space/Esc drive audition and are a shell concern, not modelled
// these) so nav math is testable without SWELL. Enter/Space/Esc are NOT here: they // here.
// 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 }; enum class NavKey { Left, Right, Up, Down, Home, End };
// Moves the focus by one step for `key` in a grid of `cols` columns holding // Moves focus by one step for `key` in a `cols`-column grid of `itemCount` cells.
// `itemCount` cells, returning the new selection. `cols` >= 1. // * Left/Right move linearly; Up/Down move by `cols`. Movement CLAMPS at the grid edges (no
// * Left/Right move by one cell in linear (row-major) order; Up/Down move by // wrap) — deliberate: wrap on a partial last row is surprising.
// `cols`. Movement CLAMPS at the grid ends (no wrap): Right on the last cell, // * Down from the row above a missing partial-last-row cell clamps to the last cell rather than
// Left on the first, Up on the top row, Down past the last cell all stay put. // overshooting past itemCount.
// (Clamp, not wrap: wrap on a partial last row is surprising and error-prone; // * Without shift: moved-to cell becomes the sole selection (focus = anchor = newIndex).
// clamp is the predictable choice — flagged as the deliberate decision.) // * With shift: focus moves to newIndex, selection becomes the inclusive range from anchor
// * Down from the second-to-last row into a column with no cell in the last row // (seeded at the origin cell on first extend).
// clamps to the last cell rather than overshooting past itemCount. // * Empty selection: first arrow focuses cell 0 without moving.
// * 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. // itemCount <= 0 returns `current` unchanged.
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount, Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
bool shift); bool shift);
// --- Waveform display compression -------------------------------------------- // --- Waveform display compression --------------------------------------------
// // Maps raw linear amplitude to a perceptual display fraction so quiet content stays visible.
// Maps a raw linear amplitude magnitude to a perceptual display fraction so
// quiet and medium content remains visible in the thumbnail. // Below this, amplitude is treated as silence (display fraction 0). Only knob for the curve.
//
// The floor below which amplitude is treated as silence (display fraction 0).
// At -60 dB, 0.001 linear magnitude maps to ~0. Tune this constant in-DAW to
// taste — it is the only knob for the compression curve.
constexpr float kDisplayFloorDb = -60.0f; constexpr float kDisplayFloorDb = -60.0f;
// Maps a signed linear amplitude value in [-1, 1] (a raw envelope extreme such // Maps a signed linear amplitude in [-1, 1] (a raw envelope extreme, e.g. PeakBin::max/min) to a
// as PeakBin::max or PeakBin::min) to a signed display fraction in [-1, 1]. // signed display fraction in [-1, 1]: magnitude -> dB, clamped to [kDisplayFloorDb, 0] and
// // normalized so the floor -> 0 and 0 dB -> 1, then the original sign is re-applied. Exact zero
// The magnitude |linear| is converted to dB, clamped to [kDisplayFloorDb, 0], // stays 0; full-scale (|linear| == 1.0f) returns exactly +-1.0f.
// then normalized so kDisplayFloorDb -> 0 and 0 dB -> 1. The original sign is
// re-applied so positive max values still map positive (draw up) and negative
// min values still map negative (draw down). Exact-zero input returns 0.0f
// (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f.
float compressAmplitudeForDisplay(float linear); float compressAmplitudeForDisplay(float linear);
} // namespace reasampler::ui } // namespace reasampler::ui
+3 -14
View File
@@ -1,4 +1,4 @@
// card_drag — pure implementation. See card_drag.h. NO REAPER / SWELL / LICE / OS / vendor. // card_drag — pure implementation. See card_drag.h.
#include "core/ui/card_drag.h" #include "core/ui/card_drag.h"
@@ -6,7 +6,6 @@ namespace reasampler::ui {
namespace { namespace {
// Half-open point-in-rect (matches drag_out / bank_grid: [x, x+w) x [y, y+h)).
bool insideClient(int px, int py, const PanelClientRect& c) { bool insideClient(int px, int py, const PanelClientRect& c) {
return px >= c.x && px < c.x + c.width && return px >= c.x && px < c.x + c.width &&
py >= c.y && py < c.y + c.height; py >= c.y && py < c.y + c.height;
@@ -16,25 +15,18 @@ bool insideClient(int px, int py, const PanelClientRect& c) {
CardGesture decideCardGesture(int px, int py, const PanelClientRect& client, CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
const DragState& state, const DragModifiers& mods) { const DragState& state, const DragModifiers& mods) {
// No drag / empty payload: nothing to do.
if (!state.dragging || !state.hasArmedSamples) return CardGesture::None; if (!state.dragging || !state.hasArmedSamples) return CardGesture::None;
// Precedence 1: pointer left the client rect -> OS drag-out (wins first).
if (!insideClient(px, py, client)) return CardGesture::OsDragOut; if (!insideClient(px, py, client)) return CardGesture::OsDragOut;
// Precedence 2: over a tab / the other bank -> move (or copy on Ctrl).
if (mods.region == DropRegion::OtherBankOrTab) if (mods.region == DropRegion::OtherBankOrTab)
return mods.ctrl ? CardGesture::Copy : CardGesture::Move; return mods.ctrl ? CardGesture::Copy : CardGesture::Move;
// Precedence 3: within the same bank's own grid -> reorder / replace.
if (mods.region == DropRegion::SameBankGrid) { if (mods.region == DropRegion::SameBankGrid) {
// Alt over an OCCUPIED slot replaces; otherwise reorder (empty = place,
// occupied+no-Alt = insert-before-and-shift).
if (mods.alt && mods.slotOccupied) return CardGesture::Replace; if (mods.alt && mods.slotOccupied) return CardGesture::Replace;
return CardGesture::Reorder; return CardGesture::Reorder;
} }
// Dead space inside the client: a drop here is a no-op.
return CardGesture::None; return CardGesture::None;
} }
@@ -56,7 +48,7 @@ std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
if (maxSlot < 0) return rects; if (maxSlot < 0) return rects;
const int cols = columnsForWidth(panelWidth, spec); const int cols = columnsForWidth(panelWidth, spec);
const int count = maxSlot + 1; // slots 0..maxSlot inclusive (empties included) const int count = maxSlot + 1;
rects.reserve(static_cast<std::size_t>(count)); rects.reserve(static_cast<std::size_t>(count));
for (int slot = 0; slot < count; ++slot) { for (int slot = 0; slot < count; ++slot) {
@@ -76,16 +68,13 @@ std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth, std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth,
const GridSpec& spec) { const GridSpec& spec) {
const int cols = columnsForWidth(panelWidth, spec); const int cols = columnsForWidth(panelWidth, spec);
// One trailing row of slots past the last occupied slot — the drop-target extension.
// When maxSlot < 0 (empty bank) the trailing row begins at slot 0.
const int firstTrailing = maxSlot + 1; const int firstTrailing = maxSlot + 1;
const int newMax = firstTrailing + cols - 1; // fills one full trailing row const int newMax = firstTrailing + cols - 1; // one full trailing row
return computeSlotRects(newMax, panelWidth, spec); return computeSlotRects(newMax, panelWidth, spec);
} }
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects) { int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects) {
for (const SlotCellRect& r : rects) { for (const SlotCellRect& r : rects) {
// Half-open bounds so adjacent rects never both claim a pixel.
if (px >= r.x && px < r.x + r.width && if (px >= r.x && px < r.x + r.width &&
py >= r.y && py < r.y + r.height) py >= r.y && py < r.y + r.height)
return r.slot; return r.slot;
+48 -95
View File
@@ -1,32 +1,20 @@
#pragma once #pragma once
// card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three // card_drag — decision logic behind the in-grid reorder drag. Mirror of drag_out::decideGesture;
// pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing // SWELL wiring, SetCursor, cursor resources, and drop-target draw stay in the shell.
// split); the SWELL wiring, SetCursor call, cursor resources, and drop-target draw stay in
// the shell (shell/panel/panel_drag.cpp). Mirror of drag_out::decideGesture.
// //
// 1. GESTURE PRECEDENCE (F3 settled). A live drag resolves to exactly one gesture, in a // Gesture precedence, evaluated on every mouse-move / at drop, strict order:
// strict precedence the shell evaluates on every mouse-move / at drop: // (1) pointer LEFT the client rect -> OsDragOut (hand off to the OS)
// (1) pointer LEFT the client rect -> OsDragOut (hand off to the OS) // (2) else drop over a tab / the OTHER bank -> Move | Copy (Ctrl = Copy)
// (2) else drop over a tab / the OTHER bank -> Move | Copy (Ctrl = Copy) // (3) else drop within the SAME bank's grid -> Reorder | Replace
// (3) else drop within the SAME bank's grid -> Reorder | Replace // - empty slot -> Reorder (place there)
// - empty slot -> Reorder (place there) // - occupied slot, no modifier -> Reorder (insert-before-and-shift)
// - occupied slot, no modifier -> Reorder (insert-before-and-shift) // - occupied slot, Alt held -> Replace
// - occupied slot, Alt held -> Replace (Alt-replace-over-occupied) // Leave-client wins first, then other-bank, then same-bank-grid — so reorder can never steal a
// So leave-client wins first, then other-bank, then same-bank-grid = reorder/replace. // bank-move or an OS-drag.
// This keeps the reorder gesture from ever stealing a bank-move or OS-drag.
// //
// 2. SLOT HIT-TEST. Which grid SLOT a pointer sits over, sparse-aware: the grid tiles // Also owns: sparse-aware slot hit-test (a point -> grid slot, empty or occupied, extending
// slots 0..maxSlot including empty ones, so hit-testing maps a point to a slot index // bank_grid's dense tiling), and the gesture -> cursor-cue mapping (Replace's cue appears only
// (empty or occupied) or -1 for a miss. The pixel<->slot rect math extends bank_grid's // when Alt is actually held over an occupied slot).
// dense tiling to the gap-preserving slot layout.
//
// 3. DROP-RESULT -> CURSOR CUE. The resolved gesture maps to a cursor cue enum the shell
// turns into a SetCursor call. The cue DECISION is pure (here); the shell owns only
// the SetCursor call and the cursor resources. The Replace cue appears ONLY when Alt
// is actually held over an occupied slot (precedence rule 3's Alt branch).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO OS, NO vendor/ includes. Standard
// library only. Reuses drag_out's PanelClientRect / DragState and bank_grid's CellRect.
#include <vector> #include <vector>
@@ -35,79 +23,54 @@
namespace reasampler::ui { namespace reasampler::ui {
// Which drop region the pointer currently sits over WITHIN the client rect. The shell // Which drop region the pointer sits over within the client rect; the shell classifies against
// classifies the live pointer against its own region geometry (tab strip / other bank // its own region geometry and passes the verdict — card_drag knows only precedence over these.
// region / this bank's own grid) and passes the verdict; card_drag does not know panel
// layout, only the precedence over these verdicts. (When the pointer has left the client
// rect the shell need not compute this — OsDragOut wins first regardless.)
enum class DropRegion { enum class DropRegion {
SameBankGrid, // over the dragged samples' OWN bank grid — a reorder/replace target SameBankGrid, // over the dragged samples' OWN bank grid — reorder/replace target
OtherBankOrTab, // over a tab or the other region's bank — a move/copy target OtherBankOrTab, // over a tab or the other region's bank — move/copy target
DeadSpace, // inside the client but over no drop target (header, footer, gap) DeadSpace, // inside the client but over no drop target
}; };
// The resolved gesture — one clean outcome the shell acts on and maps to a cursor. // The resolved gesture the shell acts on and maps to a cursor.
enum class CardGesture { enum class CardGesture {
None, // no drag under way, or an empty payload — do nothing None,
OsDragOut, // pointer left the client rect — hand off to the native OS drag (drag_out) OsDragOut,
Move, // drop over another bank/tab, no Ctrl — move the samples there Move,
Copy, // drop over another bank/tab, Ctrl held — copy the samples there Copy,
Reorder, // drop within the same bank grid — reorder to the target slot Reorder,
Replace, // drop within the same bank grid, Alt over an OCCUPIED slot — replace Replace,
}; };
// The live drag inputs the precedence decision needs beyond position + client rect: // Live drag inputs the precedence decision needs beyond position + client rect.
// region — the shell's verdict on what the pointer sits over (see DropRegion).
// targetSlot — the slot the pointer sits over in the same-bank grid, or -1 (used only
// when region == SameBankGrid to decide empty-vs-occupied).
// slotOccupied — whether targetSlot currently holds a sample (drives Reorder vs Replace).
// ctrl — Ctrl held (Copy vs Move over another bank).
// alt — Alt held (Replace vs Reorder over an occupied same-bank slot).
struct DragModifiers { struct DragModifiers {
DropRegion region = DropRegion::DeadSpace; DropRegion region = DropRegion::DeadSpace;
int targetSlot = -1; int targetSlot = -1; // slot under the pointer in SameBankGrid; -1 otherwise
bool slotOccupied = false; bool slotOccupied = false; // drives Reorder vs Replace
bool ctrl = false; bool ctrl = false; // Copy vs Move over another bank
bool alt = false; bool alt = false; // Replace vs Reorder over an occupied same-bank slot
}; };
// Resolves the gesture for a drag at pointer (px, py) over `client`, given the drag // Resolves the gesture for a drag at pointer (px, py) over `client`. See precedence above.
// `state` and the live `mods`. Precedence exactly as documented above.
// * Not dragging / no armed samples: None.
// * Pointer OUTSIDE the client rect: OsDragOut (wins first — invariant #4 boundary).
// * OtherBankOrTab: Copy if ctrl else Move.
// * SameBankGrid: Replace iff (alt AND the target slot is occupied); else Reorder
// (whether the slot is empty — place — or occupied without Alt — insert-shift).
// * DeadSpace inside the client: None (a drop here is a no-op).
CardGesture decideCardGesture(int px, int py, const PanelClientRect& client, CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
const DragState& state, const DragModifiers& mods); const DragState& state, const DragModifiers& mods);
// The cursor cue the shell should show for a resolved gesture. 1:1 with CardGesture but
// named as a cursor concern so the shell maps it to a SetCursor resource. None -> the
// default arrow. The Replace cue is produced ONLY for CardGesture::Replace (which itself
// requires Alt-over-occupied), satisfying "the replace cursor appears only while Alt is
// held over an occupied slot."
enum class CursorCue { enum class CursorCue {
Default, // arrow — no drag, or dead space Default,
Reorder, // within-bank reorder Reorder,
Move, // move to another bank/tab Move,
Copy, // copy to another bank/tab Copy,
OsDragOut, // pointer left the client (the OS drag loop owns the cursor once handed off) OsDragOut,
Replace, // Alt-replace over an occupied slot Replace,
}; };
// Maps a resolved gesture to its cursor cue (pure — the shell owns SetCursor only).
CursorCue cursorForGesture(CardGesture g); CursorCue cursorForGesture(CardGesture g);
// --- Sparse-aware slot layout + hit-test -------------------------------------- // --- Sparse-aware slot layout + hit-test --------------------------------------
// The pixel rect of one grid SLOT (empty or occupied). Distinct from bank_grid's CellRect // One grid SLOT's pixel rect (empty or occupied); carries its slot index so the shell can map a
// only in intent — a SlotCellRect carries the slot index it draws, so the shell can map a // rect back to the model slot without a parallel array.
// drawn/hit rect back to the model slot without a parallel array. width/height match the
// grid spec; (x, y) is the top-left in the region's grid-viewport coordinates (the shell
// translates by the grid origin exactly as regionCellRects does today).
struct SlotCellRect { struct SlotCellRect {
int slot = 0; // the model slot this rect represents (0..maxSlot) int slot = 0;
int x = 0; int x = 0;
int y = 0; int y = 0;
int width = 0; int width = 0;
@@ -119,29 +82,19 @@ struct SlotCellRect {
} }
}; };
// Tiles slots 0..maxSlot (INCLUSIVE) into a panel of the given pixel width, honoring the // Tiles slots [0, maxSlot] inclusive (empty slots included, so a gap draws and a drop targets it
// grid spec — the sparse-aware sibling of bank_grid::computeCellRects. Every slot in // precisely). maxSlot < 0 -> empty. Same column/row math as bank_grid::computeCellRects.
// [0, maxSlot] gets a rect (empty slots included) so a gap draws as an empty cell and a
// drop targets it precisely. `maxSlot` < 0 -> empty (no occupied slots). The rects use the
// SAME column/row math as computeCellRects (slot index in place of item index), so an
// all-dense map (slots 0..N-1) lays out identically to today's grid.
std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth, std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
const GridSpec& spec); const GridSpec& spec);
// Like computeSlotRects but extends one full trailing row of slots beyond maxSlot so a // Like computeSlotRects but extends one full trailing row past maxSlot so a drop pointer beyond
// drop pointer past the last occupied card still resolves to a valid target slot. The // the last occupied card still resolves to a valid (empty) target slot. Drop hit-testing only —
// trailing slots (maxSlot+1 .. maxSlot+cols) are empty — a drop on any of them calls // the draw path uses computeSlotRects, no ghost row in the visual.
// reorderSample with that slot index, which places the card there directly (no shift,
// because the slot is empty). Used ONLY for drop hit-testing; the draw path uses
// computeSlotRects (no trailing ghost row in the visual).
// When maxSlot < 0 the trailing row starts at slot 0 (same as a fresh bank with no cards).
std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth, std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth,
const GridSpec& spec); const GridSpec& spec);
// Hit-tests a point against slot rects (half-open bounds, matching hitTestCell). Returns // Slot index (rect.slot, NOT the vector index) of the first rect containing the point, or -1 on
// the SLOT index (rect.slot) of the first rect containing the point, or -1 on a miss (gap, // a miss. Half-open bounds, matching hitTestCell.
// margin, below the last row). NOTE the return is the slot index, NOT the vector index —
// callers reason in model slots.
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects); int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects);
} // namespace reasampler::ui } // namespace reasampler::ui
+9 -18
View File
@@ -1,4 +1,4 @@
// card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor. // card_meta — pure implementation. See card_meta.h.
#include "core/ui/card_meta.h" #include "core/ui/card_meta.h"
@@ -8,33 +8,26 @@
namespace reasampler::ui { namespace reasampler::ui {
std::string formatBarsBeats(const MusicalLength& m) { std::string formatBarsBeats(const MusicalLength& m) {
// No derivable musical read-out without a positive tempo AND a stamped meter.
if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {}; if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
const double len = m.lengthSeconds > 0.0 ? m.lengthSeconds : 0.0; const double len = m.lengthSeconds > 0.0 ? m.lengthSeconds : 0.0;
// Total beats in THIS meter. A quarter-note is 60/tempo s; a beat is (4/denom) // A quarter-note is 60/tempo s; a beat is (4/denom) quarter-notes.
// quarter-notes, so a beat lasts (60/tempo) * (4/denom) seconds. beats = len / that.
const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom); const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom);
double totalBeats = len / secondsPerBeat; double totalBeats = len / secondsPerBeat;
// Snap to an exact beat when we are within a hundredth-of-a-beat epsilon of one, so a // Snap to an exact beat within epsilon so a bar-aligned capture reads "2.1.00" rather than
// bar-aligned capture reads "2.1.00" rather than "1.4.99" from FP error just under the // "1.4.99" from FP error just under the boundary.
// boundary. The epsilon is well below the .01 display quantum, so it never mis-rounds a
// genuinely fractional length.
const double snapped = std::floor(totalBeats + 0.5); const double snapped = std::floor(totalBeats + 0.5);
if (std::fabs(totalBeats - snapped) < 1e-6) totalBeats = snapped; if (std::fabs(totalBeats - snapped) < 1e-6) totalBeats = snapped;
// Split into whole beats + a fractional remainder (0..1 of a beat).
double wholeBeats = std::floor(totalBeats); double wholeBeats = std::floor(totalBeats);
double frac = totalBeats - wholeBeats; double frac = totalBeats - wholeBeats;
// Bars/beats are 1-based; beat cycles 1..timeSigNum within a bar.
const long wb = static_cast<long>(wholeBeats); const long wb = static_cast<long>(wholeBeats);
const long bar = wb / m.timeSigNum + 1; // 1-based bar const long bar = wb / m.timeSigNum + 1;
const long beat = wb % m.timeSigNum + 1; // 1-based beat within the bar const long beat = wb % m.timeSigNum + 1;
// Subdivision: hundredths of a beat, floored (0..99). A decorative display quantum.
int sub = static_cast<int>(std::floor(frac * 100.0)); int sub = static_cast<int>(std::floor(frac * 100.0));
if (sub < 0) sub = 0; if (sub < 0) sub = 0;
if (sub > 99) sub = 99; if (sub > 99) sub = 99;
@@ -48,12 +41,10 @@ std::string formatSecondsMs(double lengthSeconds) {
double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0; double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0;
long secs = static_cast<long>(std::floor(len)); long secs = static_cast<long>(std::floor(len));
// Round to the nearest millisecond (not floor): FP error means 62.037 s stores as // Round to nearest ms, not floor: FP storage error would otherwise render e.g. "62.036"
// 62.0369999... and a raw floor would render "62.036". +0.5 before truncation rounds // for a value that should read "62.037".
// to the closest ms, which is what a wall-clock read-out should show.
int ms = static_cast<int>((len - static_cast<double>(secs)) * 1000.0 + 0.5); int ms = static_cast<int>((len - static_cast<double>(secs)) * 1000.0 + 0.5);
// Rounding can push ms to 1000 at a whole-second boundary; carry into seconds. if (ms >= 1000) { ms -= 1000; ++secs; } // rounding can carry into the next second
if (ms >= 1000) { ms -= 1000; ++secs; }
if (ms < 0) ms = 0; if (ms < 0) ms = 0;
char buf[48]; char buf[48];
+11 -35
View File
@@ -1,23 +1,14 @@
#pragma once #pragma once
// card_meta — pure formatting for the L7 decorative card metadata overlay. Each bank // card_meta — formatting for the bank card's decorative metadata overlay: capture length as
// card overlays capture length as bars.beats.subdivisions (bottom-LEFT, musical) and // bars.beats.subdivisions (bottom-left, musical) and seconds.milliseconds (bottom-right,
// seconds.milliseconds (bottom-RIGHT, wall-clock). Both read-outs are DECORATIVE and // wall-clock). Both are non-interactive; bank_panel draws them via the kit.
// non-interactive; the bank_panel draws them via the L1 kit. The formatting itself is
// pure string work over the sample's stamped tempo + meter + length, so it is
// unit-tested outside the DAW (CLAUDE.md §load-bearing split).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard
// library only. Mirror of tooltip's prefix-strip helper.
#include <string> #include <string>
namespace reasampler::ui { namespace reasampler::ui {
// The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp): // Musical length inputs, taken straight off a Sample's capture-time stamp. tempoBpm 0 = unknown;
// lengthSeconds — captured length in wall-clock seconds (>= 0). // timeSigNum/Denom 0 = unstamped.
// tempoBpm — project tempo (BPM) at capture (Sample.captureTempo); 0 = unknown.
// timeSigNum — meter numerator at capture (Sample.captureTimeSigNum); 0 = unstamped.
// timeSigDenom — meter denominator at capture (Sample.captureTimeSigDenom); 0 = unstamped.
struct MusicalLength { struct MusicalLength {
double lengthSeconds = 0.0; double lengthSeconds = 0.0;
double tempoBpm = 0.0; double tempoBpm = 0.0;
@@ -25,31 +16,16 @@ struct MusicalLength {
int timeSigDenom = 0; int timeSigDenom = 0;
}; };
// bars.beats.subdivisions from a capture-time tempo + meter stamp (musical read-out). // bars.beats.subdivisions from a capture-time tempo + meter stamp.
// //
// Derivation: one quarter-note lasts 60 / tempo seconds; a beat in this meter lasts // 1-based, zero-padded to two subdivision digits: "1.1.00" is a bar-aligned/zero-length capture,
// (4 / timeSigDenom) quarter-notes; a bar holds timeSigNum beats. From lengthSeconds we // "2.3.50" is 1 bar + 2 beats + half a beat. Unstamped meter or unknown tempo (tempoBpm <= 0)
// get total beats, split into whole bars (÷ timeSigNum) + whole leftover beats + a // returns "" — no musical read-out is derivable, caller keeps the s.ms read-out. Subdivision is
// subdivision remainder scaled to 1..N of the next beat. The output is 1-BASED and // 0..99 (hundredths of a beat), floored — a display quantum, not tick-accurate PPQ.
// zero-padded to two subdivision digits: "1.1.00" is exactly one bar-start (a
// zero-length or bar-aligned capture), "2.3.50" is 1 bar + 2 beats + half a beat.
//
// Contract / edge cases (all tested):
// * UNSTAMPED meter (timeSigNum <= 0 || timeSigDenom <= 0) OR unknown tempo
// (tempoBpm <= 0): returns "" — no musical read-out is derivable (the caller keeps
// the s.ms read-out). This is the pre-L7-sample fallback (blank musical read-out).
// * zero length: "1.1.00" (bar 1, beat 1, no subdivision) — the musical origin.
// * exact bar boundary: the beat rolls to 1 and the bar increments (never "1.5.00"
// in 4/4 — that reads as "2.1.00").
// * long captures: bars grow without cap ("129.1.00" is fine).
// The subdivision is 0..99 (hundredths of a beat), floored — a display quantum, not a
// tick-accurate PPQ (the model refuses to invent PPQ; this is a decorative read-out).
std::string formatBarsBeats(const MusicalLength& m); std::string formatBarsBeats(const MusicalLength& m);
// seconds.milliseconds from a wall-clock length (always derivable, meter-independent). // seconds.milliseconds from a wall-clock length (always derivable, meter-independent).
// * "S.mmm" — integer seconds, a dot, zero-padded 3-digit milliseconds (rounded to nearest ms). // "S.mmm", rounded to nearest ms. Negative length clamps to "0.000".
// e.g. 0.0 -> "0.000", 1.5 -> "1.500", 62.037 -> "62.037".
// * negative length is clamped to "0.000" (a length is never negative; defensive).
std::string formatSecondsMs(double lengthSeconds); std::string formatSecondsMs(double lengthSeconds);
} // namespace reasampler::ui } // namespace reasampler::ui
+4 -12
View File
@@ -1,5 +1,4 @@
// component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL / // component_geometry — pure implementation. See component_geometry.h.
// LICE / vendor. Standard library only.
#include "core/ui/component_geometry.h" #include "core/ui/component_geometry.h"
@@ -19,14 +18,13 @@ KitButtonBox computeButtonBox(const KitBox& cell, int padding) {
b.y = cell.y + padding; b.y = cell.y + padding;
b.width = cell.width - 2 * padding; b.width = cell.width - 2 * padding;
b.height = cell.height - 2 * padding; b.height = cell.height - 2 * padding;
if (b.empty()) return {}; // padding collapsed the cell -> suppress if (b.empty()) return {};
return KitButtonBox{b}; return KitButtonBox{b};
} }
SliderGeometry computeSlider(const KitBox& control, double value, SliderGeometry computeSlider(const KitBox& control, double value,
int handleSize, int trackThickness) { int handleSize, int trackThickness) {
if (control.empty() || handleSize <= 0 || trackThickness <= 0) return {}; if (control.empty() || handleSize <= 0 || trackThickness <= 0) return {};
// The handle must fit in both axes; too small -> nothing sensible to draw.
if (control.width < handleSize || control.height < handleSize) return {}; if (control.width < handleSize || control.height < handleSize) return {};
if (value < 0.0) value = 0.0; if (value < 0.0) value = 0.0;
@@ -34,8 +32,6 @@ SliderGeometry computeSlider(const KitBox& control, double value,
const int half = handleSize / 2; const int half = handleSize / 2;
// Track: horizontally inset by half the handle at each end so the handle's centre
// travels only within the control; vertically centred at trackThickness.
KitBox track; KitBox track;
track.x = control.x + half; track.x = control.x + half;
track.width = control.width - handleSize; // travel span for the handle centre track.width = control.width - handleSize; // travel span for the handle centre
@@ -43,7 +39,6 @@ SliderGeometry computeSlider(const KitBox& control, double value,
track.height = trackThickness; track.height = trackThickness;
track.y = control.y + (control.height - trackThickness) / 2; track.y = control.y + (control.height - trackThickness) / 2;
// Handle centre travels [track.x, track.x + track.width]; its box is centred on that.
const int centre = track.x + static_cast<int>(value * track.width + 0.5); const int centre = track.x + static_cast<int>(value * track.width + 0.5);
KitBox handle; KitBox handle;
handle.x = centre - half; handle.x = centre - half;
@@ -51,7 +46,6 @@ SliderGeometry computeSlider(const KitBox& control, double value,
handle.width = handleSize; handle.width = handleSize;
handle.height = handleSize; handle.height = handleSize;
// Filled portion: from the track's left up to the handle centre.
KitBox filled; KitBox filled;
filled.x = track.x; filled.x = track.x;
filled.y = track.y; filled.y = track.y;
@@ -79,24 +73,22 @@ double sliderValueAt(int px, const KitBox& control, int handleSize) {
ListRowBox computeListRow(const KitBox& list, int index, int rowHeight) { ListRowBox computeListRow(const KitBox& list, int index, int rowHeight) {
if (list.empty() || rowHeight <= 0 || index < 0) return {}; if (list.empty() || rowHeight <= 0 || index < 0) return {};
const int top = list.y + index * rowHeight; const int top = list.y + index * rowHeight;
// Fully below the list bottom -> clipped away entirely -> no box.
if (top >= list.y + list.height) return {}; if (top >= list.y + list.height) return {};
KitBox b; KitBox b;
b.x = list.x; b.x = list.x;
b.y = top; b.y = top;
b.width = list.width; b.width = list.width;
b.height = rowHeight; // a partially-visible last row keeps full height; caller clips b.height = rowHeight;
return ListRowBox{index, b}; return ListRowBox{index, b};
} }
int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount) { int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount) {
if (list.empty() || rowHeight <= 0 || rowCount <= 0) return -1; if (list.empty() || rowHeight <= 0 || rowCount <= 0) return -1;
// Outside the list band entirely.
if (px < list.x || px >= list.x + list.width || if (px < list.x || px >= list.x + list.width ||
py < list.y || py >= list.y + list.height) py < list.y || py >= list.y + list.height)
return -1; return -1;
const int row = (py - list.y) / rowHeight; const int row = (py - list.y) / rowHeight;
if (row < 0 || row >= rowCount) return -1; // in the empty tail past the last row if (row < 0 || row >= rowCount) return -1;
return row; return row;
} }
+37 -81
View File
@@ -1,100 +1,64 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// component_geometry — the REAPER-free, LICE-free geometry + hit-test math for the shared // component_geometry — geometry + hit-test math for the shared drawing kit's generic components:
// drawing kit's generic components (Phase L, L1): a button box, a slider's track/handle, // a button box, a slider's track/handle, and a list row. bank_grid / tab_strip / prune_button
// and a list row. These are the kit-level primitives that DON'T already have a pure owner: // stay the source of truth for the surfaces they own; this carries only the reusable component
// bank_grid / mode_switch / tab_strip / prune_button stay the source of truth for the
// surfaces THEY own; this module carries only the new, reusable component
// shapes the kit's drawButton / drawSlider / drawListRow draw against. // shapes the kit's drawButton / drawSlider / drawListRow draw against.
//
// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): even where the draw shell reuses
// a WDL/vwnd drawing idiom, the hit-test geometry stays HERE, unit-tested outside the DAW —
// vwnd's retained-mode controls own their hit-test internally, which this deliberately does
// NOT import. The shell asks this module where a handle is and whether a point hit a row.
//
// NAME NOTE (brief §name-collision): the surrounding modules already own ButtonRect /
// SegmentRect / CellRect / FooterRect etc. in this namespace, so this module's types are
// named KitButtonBox / SliderGeometry / ListRowBox to avoid collision — checked with grep
// before minting. They are distinct concepts (kit-generic component boxes vs. a specific
// surface's hit rects), so the separate names are correct, not merely non-colliding.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER. Mirror of mode_switch / prune_button.
namespace reasampler::ui { namespace reasampler::ui {
// A generic pixel box, top-left origin (SWELL/LICE convention). Shared shape for the kit // A generic pixel box, top-left origin. empty() means "nothing to draw / hit".
// component rects below. A zero-area box (empty()) means "nothing to draw / hit" — the using KitBox = Rect;
// same graceful-suppression convention prune_button uses.
using KitBox = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// True iff (px, py) falls inside `box`, half-open bounds [x, x+width) x [y, y+height) // Half-open bounds [x, x+width) x [y, y+height); an empty box claims no point.
// the same discipline as every sibling hit-test so draw and hit-test never double-claim a
// pixel. An empty box claims no point (always false).
bool hitTestBox(int px, int py, const KitBox& box); bool hitTestBox(int px, int py, const KitBox& box);
// --- Button ------------------------------------------------------------------ // --- Button ------------------------------------------------------------------
//
// A button drawn inside a host cell, inset by a uniform padding so it reads as a raised // A button drawn inside a host cell, inset by uniform padding so it reads as raised rather than
// control rather than a full-bleed fill (the kit's drawButton draws the micro-gradient // full-bleed. Distinct from prune_button, which owns its own placement within its strip.
// surface inside this box). Distinct from prune_button, which owns its OWN placement
// within its strip — this is the generic "given a cell, where's the
// button" helper for new kit consumers.
struct KitButtonBox { struct KitButtonBox {
KitBox box; KitBox box;
bool operator==(const KitButtonBox& o) const { return box == o.box; } bool operator==(const KitButtonBox& o) const { return box == o.box; }
}; };
// The button box inside `cell`, inset uniformly by `padding` on all four sides. Returns an // Button box inside `cell`, inset uniformly by `padding`. Returns an empty box (suppressed) when
// empty box (suppressed) when the cell is degenerate or the padding would collapse it to // the cell is degenerate or padding would collapse it to zero-or-negative area. padding < 0 -> 0.
// zero-or-negative area — the caller then draws nothing (graceful, mirrors prune_button).
// padding < 0 is treated as 0.
KitButtonBox computeButtonBox(const KitBox& cell, int padding); KitButtonBox computeButtonBox(const KitBox& cell, int padding);
// --- Slider (horizontal) ----------------------------------------------------- // --- Slider (horizontal) -----------------------------------------------------
//
// A horizontal slider: a track spanning the control width (inset at both ends by the // track spans the control width, inset at both ends by half the handle width so the handle never
// handle's half-width so the handle never clips past the track), and a square handle // clips past it. handle is centered on the track, positioned by the normalized value.
// centered on the track and positioned by the normalized value. drawSlider draws the
// track, the filled portion up to the handle, and the handle. Hit-test is against the
// handle (grab) and the track (jump); both are pure here.
struct SliderGeometry { struct SliderGeometry {
KitBox track; // the full track rect (the groove) KitBox track;
KitBox filled; // the filled portion from the track's left up to the handle center KitBox filled; // filled portion from track's left up to the handle center
KitBox handle; // the draggable handle rect KitBox handle;
bool operator==(const SliderGeometry& o) const { bool operator==(const SliderGeometry& o) const {
return track == o.track && filled == o.filled && handle == o.handle; return track == o.track && filled == o.filled && handle == o.handle;
} }
}; };
// Lays out a horizontal slider inside `control` for a normalized `value` in [0, 1] with a // Lays out a horizontal slider inside `control` for normalized `value` in [0, 1] with a square
// square handle of side `handleSize`. The track is vertically centered at a fixed // handle of side `handleSize`, track vertically centered at `trackThickness`. value clamps to
// `trackThickness`, inset horizontally by handleSize/2 at each end so the handle's travel // [0, 1]. Returns all-empty boxes when the control is too small to host the handle, or when
// stays within `control`. value is clamped to [0, 1]; a value of 0 puts the handle flush // handleSize/trackThickness <= 0.
// left, 1 flush right. Returns all-empty boxes when the control is degenerate or too
// small to host the handle (control width < handleSize or height < handleSize) — the
// caller draws nothing. handleSize <= 0 or trackThickness <= 0 also yields empty.
SliderGeometry computeSlider(const KitBox& control, double value, SliderGeometry computeSlider(const KitBox& control, double value,
int handleSize, int trackThickness); int handleSize, int trackThickness);
// The normalized value [0, 1] a click at px maps to, for a slider laid out in `control` // Inverse of computeSlider's handle placement (a track-jump click): normalized value [0, 1] a
// with `handleSize` (the inverse of computeSlider's handle placement — a track jump). // click at px maps to. Clamps to [0, 1] outside the track; 0.0 for a degenerate/too-small
// px left of / at the track start yields 0.0, at/right of the track end yields 1.0, // control. py unused (horizontal slider maps X only) — caller gates with hitTestBox(control) first.
// linear in between. Returns 0.0 for a degenerate/too-small control (no travel). py is
// unused (a horizontal slider maps X only); the caller gates the whole slider region
// with hitTestBox(control) before calling this.
double sliderValueAt(int px, const KitBox& control, int handleSize); double sliderValueAt(int px, const KitBox& control, int handleSize);
// --- List row ---------------------------------------------------------------- // --- List row ----------------------------------------------------------------
//
// A single selectable row in a vertical list: full-width, fixed height, stacked from the // One selectable row: full-width, fixed height, stacked from the list's top by index (no scroll
// list's top by index (no scroll — the caller offsets the list origin for scroll). The // caller offsets the list origin for that).
// kit's drawListRow draws the row surface (rest/hover/selected/focus) and an optional
// leading thumbnail; the panel's waveform cell is a specialization drawn the same way.
struct ListRowBox { struct ListRowBox {
int index = 0; // the row's index in the caller's list (0-based, top-first) int index = 0;
KitBox box; KitBox box;
bool operator==(const ListRowBox& o) const { bool operator==(const ListRowBox& o) const {
@@ -102,28 +66,20 @@ struct ListRowBox {
} }
}; };
// The row box for `index` in a list laid out inside `list` at `rowHeight` per row. Rows // Row box for `index` inside `list` at `rowHeight` per row; rows stack from list.y. Empty when
// stack from list.y; row i spans [list.y + i*rowHeight, +rowHeight). Returns an empty box // the list is degenerate, rowHeight <= 0, index < 0, or the row falls entirely below the list's
// when the list is degenerate, rowHeight <= 0, index < 0, or the row would fall entirely // bottom. A partially-visible last row IS returned — caller clips the draw.
// below the list's bottom (fully clipped) — a partially-visible last row IS returned (the
// caller clips the draw). This is layout only; the caller decides how many rows exist.
ListRowBox computeListRow(const KitBox& list, int index, int rowHeight); ListRowBox computeListRow(const KitBox& list, int index, int rowHeight);
// The index of the row a point (px, py) lands on, for a list laid out inside `list` at // Index of the row a point lands on, or -1 for a miss (outside bounds, or in the empty tail past
// `rowHeight`. Returns -1 for a miss: outside the list bounds, in the list band but below // `rowCount` rows). rowCount bounds the hit so blank space past the last row is a clean miss.
// the last row of `rowCount` rows (the empty tail), or a degenerate list/rowHeight/count.
// rowCount bounds the hit so a click in blank space past the last row is a clean miss, not
// a phantom row. Half-open bounds match computeListRow so the hit maps to the drawn row.
int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount); int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount);
// --- Waveform column count --------------------------------------------------- // --- Waveform column count ---------------------------------------------------
//
// The number of pixel columns drawWaveform renders inside `box` (its fixed 2px side // Pixel columns drawWaveform renders inside `box` (its fixed 2px side insets), never negative.
// insets), never negative. Callers pass this count directly as the `binCount` argument to // Pass directly as peaks::computeEnvelope's binCount — one bin per column is correct resolution;
// peaks::computeEnvelope — one bin per column is the correct resolution, and // overbinning doesn't improve render quality and wastes memory/CPU.
// peaks::columnMinMax's exact partition makes the render gap-free at any bins-to-pixels
// ratio. Overbinning does NOT improve render quality (columnMinMax's frame union is
// identical whether bins == columns or bins == k*columns) and wastes memory and CPU.
int waveformColumnCount(const KitBox& box); int waveformColumnCount(const KitBox& box);
} // namespace reasampler::ui } // namespace reasampler::ui
+6 -9
View File
@@ -1,4 +1,4 @@
// drag_out — pure implementation. See drag_out.h. NO REAPER / SWELL / OS / vendor. // drag_out — pure implementation. See drag_out.h.
#include "core/ui/drag_out.h" #include "core/ui/drag_out.h"
@@ -8,7 +8,6 @@ namespace reasampler::ui {
namespace { namespace {
// Half-open point-in-rect (matches the panel's other hit-tests: [x, x+w) x [y, y+h)).
bool insideClient(int px, int py, const PanelClientRect& c) { bool insideClient(int px, int py, const PanelClientRect& c) {
return px >= c.x && px < c.x + c.width && return px >= c.x && px < c.x + c.width &&
py >= c.y && py < c.y + c.height; py >= c.y && py < c.y + c.height;
@@ -20,10 +19,8 @@ DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state) { const DragState& state) {
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None; if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
if (insideClient(px, py, client)) return DragGesture::Internal; if (insideClient(px, py, client)) return DragGesture::Internal;
// Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is // Outside the client: a single-capture drag still over REAPER's own UI is an instrument
// still over REAPER's own UI is an instrument drop (heading for a track's FX button); // drop; anything else (multi-capture, or pointer off REAPER entirely) is an OS drag-out.
// anything else (a multi-capture payload, or the pointer off REAPER entirely) is the
// unchanged M11 OS drag-out.
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop; if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
return DragGesture::OsDrag; return DragGesture::OsDrag;
} }
@@ -34,15 +31,15 @@ PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
seen.reserve(resolved.size()); seen.reserve(resolved.size());
for (const ResolvedSample& s : resolved) { for (const ResolvedSample& s : resolved) {
if (s.absolutePath.empty()) { // shell could not resolve it if (s.absolutePath.empty()) {
++out.skippedUnresolved; ++out.skippedUnresolved;
continue; continue;
} }
if (!s.fileExists) { // stale index entry, file gone if (!s.fileExists) {
++out.skippedMissing; ++out.skippedMissing;
continue; continue;
} }
if (!seen.insert(s.absolutePath).second) { // already emitted this path if (!seen.insert(s.absolutePath).second) {
++out.skippedDuplicate; ++out.skippedDuplicate;
continue; continue;
} }
+37 -97
View File
@@ -1,31 +1,17 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS // drag_out — decision logic behind the bank_panel's native OS drag-out. OLE/SWELL initiation and
// drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are // the panel gesture hook stay in the shell (drag_out_win.* + shell/panel/panel_drag.cpp).
// unit-tested outside the DAW (CLAUDE.md §load-bearing split); the OLE / SWELL initiation
// and the bank_panel gesture hook stay in the shell (drag_out_win.* + shell/panel/panel_drag.cpp).
// //
// 1. GESTURE BOUNDARY (invariant #4 — do not regress the internal drag). The panel // Gesture boundary: the panel's own internal drag (press a selected cell, drop onto a pool/bank
// already runs an INTERNAL drag: press a selected cell, cross a threshold, drop onto // region or tab) lives entirely inside the panel client rect. The moment the pointer LEAVES that
// a pool/banks region or a tab to move/copy the samples between banks. That drag lives // rect while a drag is armed with samples, the gesture becomes OS-bound — dragged out to another
// entirely INSIDE the panel client rect. The OS drag is a DISTINCT gesture with a // window/Explorer/DAW. A single-capture drag that leaves the rect but is still over REAPER's own
// distinct, discoverable boundary: while a drag is armed with samples in the payload, // UI is instead an InstrumentDrop (heading for a track's FX button); do not regress this boundary.
// the moment the pointer LEAVES the panel client area the gesture becomes OS-bound —
// the payload is being dragged out to another window / Explorer / another DAW. Inside
// the client area it stays internal; with no armed samples there is no drag at all.
// This function is that decision, pure over (drag state + pointer + panel rect).
// //
// 2. PATH-LIST ASSEMBLY. The OS drop carries absolute file paths (Windows CF_HDROP / // Path-list assembly: turns armed sample ids into the absolute path list the OS drop carries
// macOS file-list pasteboard). Turning the armed sample ids into that path list — // (Windows CF_HDROP / macOS file-list pasteboard) — set algebra only; the shell resolves each id
// resolving each id to its already-on-disk bank file, de-duping, and applying an // to its on-disk bank file. No temp files; copy-only is enforced at the OS layer (drag_out_win).
// explicit skip-missing-file policy — is pure string work over a resolver the shell
// supplies (the shell owns the REAPER project-dir read + resolveBankFile; this module
// owns the set algebra and the result contract). NO temp files: the bank files already
// exist; the list points straight at them (COPY-ONLY is enforced at the OS layer — see
// drag_out_win — never by relocating or copying bytes here).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO OS/OLE, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER. Mirror of mode_switch.
#include <string> #include <string>
#include <vector> #include <vector>
@@ -34,99 +20,53 @@ namespace reasampler::ui {
// --- Gesture boundary --------------------------------------------------------- // --- Gesture boundary ---------------------------------------------------------
// The panel's client rectangle in its own client coordinates (top-left origin, the SWELL/ // The panel's client rect, own client coords, top-left origin. Half-open: [x, x+width) x
// LICE convention). width/height are the extents; a point (px, py) is INSIDE when // [y, y+height).
// x <= px < x + width and y <= py < y + height (half-open, matching the panel's other using PanelClientRect = Rect;
// hit-tests so the edge is claimed consistently).
using PanelClientRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// The live drag state the shell tracks, reduced to what the boundary decision needs: // Live drag state reduced to what the boundary decision needs. Pre-threshold "armed but not yet
// whether a drag is currently active (threshold crossed) and whether the armed payload // dragging" is not a drag for this decision.
// carries at least one sample. (Pre-threshold "armed but not yet dragging" is NOT a drag
// for this decision — the shell only asks once a drag is under way.)
//
// S17 (drop-and-load) adds two inputs that refine the OUTSIDE-the-panel decision without
// touching the INSIDE decision (the internal bank-to-bank drag stays byte-identical):
// * singleCapture — the payload holds EXACTLY ONE sample id. Only a single-capture drag
// arms the InstrumentDrop gesture (per the S17 open-question lean: a multi-capture drag
// over an FX button is NOT an instrument drop — it falls through to OsDrag, the natural
// multi-file drag-out to Explorer/another DAW). REJECT, not load-first: the whole gesture
// is "make ONE capture a playable instrument", so a multi payload is out of contract here.
// * overReaperUi — a SHELL-SUPPLIED predicate: true when the pointer, though outside the
// panel client rect, is still over REAPER's OWN window/UI (the shell owns the REAPER
// hit query, e.g. GetThingFromPoint; the pure layer owns only the set/boundary algebra).
// Both default false, so an M11-era caller that fills only {dragging, hasArmedSamples} gets
// EXACTLY the M11 behavior: outside the client rect with overReaperUi=false -> OsDrag.
struct DragState { struct DragState {
bool dragging = false; // threshold crossed; a drag is in progress bool dragging = false; // threshold crossed; a drag is in progress
bool hasArmedSamples = false; // the drag payload holds >= 1 sample id bool hasArmedSamples = false; // payload holds >= 1 sample id
bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop) bool singleCapture = false; // payload holds EXACTLY one sample (arms InstrumentDrop)
bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied) bool overReaperUi = false; // pointer is over REAPER's own UI (shell-supplied)
}; };
// What the shell should do with the drag given the current pointer position. // What the shell should do with the drag given the current pointer position.
enum class DragGesture { enum class DragGesture {
None, // no drag under way, or an empty payload — do nothing None, // no drag under way, or an empty payload
Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag Internal, // dragging inside the panel — bank-to-bank move/copy
InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI — InstrumentDrop, // single-capture drag left the panel but is over REAPER's UI — shell
// the shell hover-tracks the TCP FX button and, on release, adds a // hover-tracks the TCP FX button; on release adds a preloaded instance
// ReaSampler 9000 instance preloaded with the dragged capture. OsDrag, // dragging with samples, pointer left REAPER entirely — hand to the OS
OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS
}; };
// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. // Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. Position-only
// * Not dragging (or no armed samples): None — the shell ignores the move. // + state-only (no hidden state), so re-entry back inside always returns Internal.
// * Dragging with samples, pointer INSIDE the client rect: Internal — unchanged
// bank-to-bank behavior (invariant #4: the internal drag stays byte-identical).
// * Dragging OUTSIDE the client rect, SINGLE capture, over REAPER's UI: InstrumentDrop —
// the drag is heading for a track's FX button (S17); the shell hover-tracks + highlights.
// * Dragging OUTSIDE the client rect otherwise (multi-capture, OR the pointer has left
// REAPER entirely): OsDrag — the samples are leaving to the OS; the shell initiates the
// native OS drag with the resolved paths.
// The INSIDE decision is untouched (M11 internal drag is byte-identical). The M11 boundary
// (left the client rect -> OsDrag) is REFINED, not replaced: leaving the rect now asks
// "single-capture and over REAPER's UI -> InstrumentDrop, else -> OsDrag" — so the M11
// OS-drag-out (multi payload, or pointer off REAPER) keeps its exact behavior. Position-only
// + state-only (no hidden state), so re-entry back inside returns Internal.
DragGesture decideGesture(int px, int py, const PanelClientRect& client, DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state); const DragState& state);
// --- Path-list assembly ------------------------------------------------------- // --- Path-list assembly -------------------------------------------------------
// One armed sample reduced to what path assembly needs: the resolved ABSOLUTE file path // One armed sample reduced to what path assembly needs: the shell-resolved absolute path (empty
// the shell computed for it (empty when the shell could not resolve it — e.g. no project // if unresolvable) and whether it exists on disk.
// dir / empty relative path). The shell resolves each via the SAME machinery the panel
// already uses for audition/insert (resolveBankFile over the current project dir), so the
// drag points at the real bank file — no temp copy.
struct ResolvedSample { struct ResolvedSample {
std::string absolutePath; // resolved absolute path, or "" when unresolvable std::string absolutePath;
bool fileExists = false; // shell stat() result — drives the skip-missing policy bool fileExists = false;
}; };
// The outcome of assembling the drag's path list: the de-duped, existing-only absolute // Outcome of assembling the drag's path list. An empty `paths` means nothing draggable — do not
// paths to hand to the OS, plus explicit tallies so the shell can decide whether to // start a drag.
// initiate at all (an empty `paths` means nothing draggable — do NOT start a drag).
struct PathList { struct PathList {
std::vector<std::string> paths; // de-duped, existing files, in first-seen order std::vector<std::string> paths; // de-duped, existing files, first-seen order
int skippedMissing = 0; // resolved but file did not exist (skip policy) int skippedMissing = 0; // resolved but file doesn't exist (stale index entry)
int skippedUnresolved = 0; // shell could not resolve a path at all int skippedUnresolved = 0; // shell couldn't resolve a path at all
int skippedDuplicate = 0; // same absolute path seen more than once int skippedDuplicate = 0; // same absolute path seen more than once
}; };
// Assembles the drag path list from the resolved samples (in selection order). // Assembles the drag path list from the resolved samples (selection order). Comparison is
// Policy (all explicit, all tested): // exact-string — the shell normalizes case/slashes upstream if it wants Windows-style dedup.
// * SKIP-MISSING: a sample whose file does not exist on disk is skipped (counted in
// skippedMissing) — a stale index entry must never put a dangling path on the OS
// clipboard. This is the deliberate skip policy the brief asks be made explicit.
// * SKIP-UNRESOLVED: an empty absolutePath (shell could not resolve) is skipped
// (skippedUnresolved) — same reasoning, no empty entry reaches the OS.
// * DEDUPE: the same absolute path appearing twice (two index entries, one file — the
// cross-bank copy case) yields ONE CF_HDROP entry (skippedDuplicate counts the extras),
// so the OS never sees a duplicate drop path. First occurrence wins; order preserved.
// * EMPTY SELECTION: an empty input yields an empty PathList (all tallies zero) — the
// shell reads paths.empty() and does not start a drag.
// Comparison is exact-string (the shell normalizes slashes/case upstream if it wants
// case-insensitive dedup on Windows — the pure layer does not guess a platform rule).
PathList assemblePathList(const std::vector<ResolvedSample>& resolved); PathList assemblePathList(const std::vector<ResolvedSample>& resolved);
} // namespace reasampler::ui } // namespace reasampler::ui
+3 -5
View File
@@ -1,4 +1,4 @@
// footer_bar — pure implementation. See footer_bar.h. NO REAPER / SWELL / LICE / vendor. // footer_bar — pure implementation. See footer_bar.h.
#include "core/ui/footer_bar.h" #include "core/ui/footer_bar.h"
@@ -6,8 +6,7 @@ namespace reasampler::ui {
namespace { namespace {
// True iff a box [x, x+width) fits entirely left of `rightBound` (its right edge does not // True iff a box [x, x+width) fits entirely left of `rightBound`.
// cross the reserved right region). A non-positive width never "fits" (nothing to place).
bool fitsLeftOf(int x, int width, int rightBound) { bool fitsLeftOf(int x, int width, int rightBound) {
return width > 0 && x + width <= rightBound; return width > 0 && x + width <= rightBound;
} }
@@ -26,8 +25,7 @@ FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec&
const int boxH = footer.height - 2 * spec.verticalInset; const int boxH = footer.height - 2 * spec.verticalInset;
if (boxH <= 0) return out; if (boxH <= 0) return out;
// The right bound the LEFT group must stay clear of (prune + version region). Clamp so a // Clamp so a pathologically large rightReserve never yields a negative bound.
// pathologically large rightReserve never yields a negative bound.
int rightBound = footer.x + footer.width - spec.rightReserve; int rightBound = footer.x + footer.width - spec.rightReserve;
if (rightBound < footer.x) rightBound = footer.x; if (rightBound < footer.x) rightBound = footer.x;
+30 -72
View File
@@ -1,81 +1,46 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4 // footer_bar — layout + hit-test for the bank_panel footer's LEFT group: the [Arrange|Design]
// footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count // mode toggle, its compact count label, and the Tail button, left-to-right at the footer's left.
// label, and the Tail button, laid out left-to-right at the footer's left. The panel shell
// (shell/panel/) owns the SWELL window, LICE drawing, and the click dispatch (cycle tail /
// activate a mode); what is NOT DAW-bound — WHERE the toggle box, the count label, and the
// Tail button sit, and which one a click lands on — lives here so it is unit-tested outside
// the DAW (CLAUDE.md §load-bearing split). Mirror of action_bar / mode_switch / prune_button.
//
// -- Footer affordance order (L4, left -> right) -------------------------------
// //
// Affordance order, left -> right:
// [Arrange|Design] toggle . count label . Tail button . ... . Prune (rightmost, warn) // [Arrange|Design] toggle . count label . Tail button . ... . Prune (rightmost, warn)
// The view/session controls group at the left; Prune stays isolated at the far right, warn-
// colored (the only byte-deleting affordance) and owned separately by prune_button — footer_bar
// reserves a right margin (rightReserve) so its own affordances never run under it.
// //
// The two view/session controls (mode toggle, tail) group at the LEFT as the "how this // The toggle here is only the overall BOX; the shell hands its width to mode_switch
// panel/capture behaves" cluster; Prune stays isolated at the far RIGHT, warn-colored and // (computeSegmentRects / hitTestSegment) for per-segment tiling — mode_switch stays the one
// set apart (it is the only byte-deleting affordance). This module lays out the LEFT group // owner of segment geometry.
// ONLY — the rightmost Prune button remains owned by prune_button (computePruneButton), so
// the two never fight over the same pixels. footer_bar reserves a right margin (rightReserve)
// so its own affordances never run under the prune button's region.
//
// The mode toggle is drawn as an N-segment control (2 segments for Arrange|Design; N general).
// footer_bar returns only the toggle's BOX (fit to its text width); the shell hands that box's
// width to the pure mode_switch (computeSegmentRects / hitTestSegment) for the per-segment
// tiling and hit-test, so mode_switch stays the ONE owner of segment geometry. footer_bar
// decides the toggle's placement + overall width; mode_switch subdivides it.
//
// Naming: the rect-role family (ButtonRect / FooterRect / FooterBarRect / ...) is unified on
// the ONE concrete ui::Rect (core/ui/rect.h, Q-W1 T2-05) — the per-role names are aliases, so
// the former hand-collision bookkeeping is retired. FooterRect (prune_button) remains the
// shared input-strip spelling; this module's output/spec/hit types carry the FooterBar* prefix.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include "core/ui/prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted) #include "core/ui/prune_button.h" // FooterRect — the shared footer strip input type
namespace reasampler::ui { namespace reasampler::ui {
// One placed affordance's pixel rectangle within the footer, top-left origin. A zero-area rect // One placed affordance's rect, top-left origin. empty() means "not placed" (footer too narrow
// (empty()) means "not placed" (the footer was too narrow to host it after the ones before it), // after earlier affordances claimed their space) — shell draws/hit-tests nothing for it.
// so the shell draws/hit-tests nothing for it — graceful degradation, mirroring prune_button. using FooterBarRect = Rect;
using FooterBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// The laid-out footer LEFT group: the mode toggle box, the count label box, and the Tail // The laid-out footer LEFT group. Any box may be empty when the footer is too narrow to fit it
// button box, in left-to-right order. Any box may be empty (suppressed) when the footer is // left of the reserved right margin; placement is greedy left-to-right (toggle survives longest,
// too narrow to fit it left of the reserved right margin — placement is greedy left-to-right, // Tail drops first on a very narrow footer).
// so an earlier affordance survives while a later one drops (the toggle is most important,
// the Tail button drops first on a very narrow footer).
struct FooterBarLayout { struct FooterBarLayout {
FooterBarRect toggle; // the [Arrange|Design] segmented control's overall box FooterBarRect toggle;
FooterBarRect count; // the compact per-mode count label (right of the toggle) FooterBarRect count;
FooterBarRect tail; // the Tail button (right of the count label) FooterBarRect tail;
bool operator==(const FooterBarLayout& o) const { bool operator==(const FooterBarLayout& o) const {
return toggle == o.toggle && count == o.count && tail == o.tail; return toggle == o.toggle && count == o.count && tail == o.tail;
} }
}; };
// Which footer LEFT-group affordance a point landed on (or None for a miss / a suppressed // Which footer LEFT-group affordance a point landed on. Prune is hit-tested separately via
// affordance). Prune is NOT here — the shell hit-tests it separately via hitTestPruneButton. // hitTestPruneButton.
enum class FooterHit { None, Toggle, Tail }; enum class FooterHit { None, Toggle, Tail };
// Layout inputs for the footer LEFT group, in pixels. Defaults are the bank_panel footer // Layout inputs, in pixels; defaults are the bank_panel footer metrics.
// metrics; the shell passes its own so draw and hit-test share ONE source of truth. // * rightReserve — pixels reserved at the footer's right for the prune button + version
// * toggleWidth — the [Arrange|Design] toggle's overall width. Sized to fit its two // readout; footer_bar never places an affordance whose right edge would cross into it.
// segment labels comfortably (a NARROW control, per L4 §3 — no longer the
// full-width top header). The shell picks this to fit its text; the pure
// module treats it as a fixed input.
// * countWidth — the compact per-mode count label's width (e.g. "2 tracks"). 0 hides it.
// * tailWidth — the Tail button's width (fits "Tail: Manual 8.0s" comfortably).
// * gap — horizontal gap between adjacent affordances.
// * leftPad — inset from the footer left edge to the toggle's left edge.
// * verticalInset — top/bottom gap inside the footer so the controls read as raised, not
// full-height fills (matches prune_button's verticalInset).
// * rightReserve — pixels reserved at the footer's RIGHT for the prune button + version
// readout region; footer_bar never places an affordance whose right edge
// would cross into (footer.right - rightReserve). Keeps the LEFT group
// clear of the RIGHT prune/version region without those modules coupling.
struct FooterBarSpec { struct FooterBarSpec {
int toggleWidth = 132; int toggleWidth = 132;
int countWidth = 64; int countWidth = 64;
@@ -86,21 +51,14 @@ struct FooterBarSpec {
int rightReserve = 168; // clears prune_button (rightInset 84 + width 72) + margin int rightReserve = 168; // clears prune_button (rightInset 84 + width 72) + margin
}; };
// Lays out the footer LEFT group inside `footer` per `spec`, left-to-right: toggle, then the // Lays out the footer LEFT group inside `footer` per `spec`: toggle, count label, Tail button,
// count label, then the Tail button, each `gap` px apart, starting at footer.left + leftPad, // each `gap` px apart from footer.left + leftPad. Greedy — an affordance places only if it fits
// vertically centred by verticalInset. Greedy: an affordance is placed only if its whole box // left of (footer.right - rightReserve); once one doesn't fit, the rest are suppressed too.
// fits left of (footer.right - rightReserve); otherwise it (and, since placement is ordered, // countWidth <= 0 suppresses the count label without leaving a gap for the Tail button.
// it alone or the ones after it) is suppressed (empty box). A degenerate footer (width/height
// <= 0) yields an all-empty layout. countWidth <= 0 suppresses the count label (and the gap
// that would precede the Tail button collapses so the Tail sits right after the toggle).
FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec); FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec);
// The footer LEFT-group affordance the point (px, py) (SWELL/LICE top-left client coords) lands // The affordance (px, py) lands on, or FooterHit::None for a miss (or a hit on the count label,
// on, or FooterHit::None for a miss (outside every placed box, or on the count label — which is // a passive readout, never a control). Half-open bounds match computeFooterBar.
// a passive readout, not a control). Half-open bounds [x, x+width) x [y, y+height) match
// computeFooterBar so draw and hit-test agree on the same pixels. An empty (suppressed) box
// never claims a point. The shell checks the toggle hit FIRST for a segment sub-hit (via
// mode_switch over the toggle box), then the Tail hit; this returns which region was struck.
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout); FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout);
} // namespace reasampler::ui } // namespace reasampler::ui
+3 -5
View File
@@ -1,18 +1,16 @@
// mode_enable — pure implementation. See mode_enable.h. NO REAPER / SWELL / LICE / vendor. // mode_enable — pure implementation. See mode_enable.h.
#include "core/ui/mode_enable.h" #include "core/ui/mode_enable.h"
#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids #include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId
namespace reasampler::ui { namespace reasampler::ui {
bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) {
// The target's own mode id, so the rule is a single "target != active" compare.
const char* targetId = const char* targetId =
(target == TagTarget::Arrange) ? kArrangeModeId : kDesignModeId; (target == TagTarget::Arrange) ? kArrangeModeId : kDesignModeId;
// Fail-open on an unrecognized active id (neither seed mode): every button live, so a // Fail-open on an unrecognized active id: every button live.
// future added mode never dead-locks the bar and the user can always reach the action.
if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true; if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true;
return activeModeId != targetId; return activeModeId != targetId;
+8 -25
View File
@@ -1,39 +1,22 @@
#pragma once #pragma once
// mode_enable — the REAPER-free opposite-mode enablement predicate behind the bank_panel BOTTOM // mode_enable — enablement predicate behind the bank_panel bottom toolbar's four Item/Track x
// toolbar's four Item/Track × Arrange/Design tag buttons (Phase L, L5, refinement 3). Each tag // Arrange/Design tag buttons. A tag button sends the selection to a TARGET mode; it's live only
// button sends the selection to a TARGET mode; a button is meaningful ONLY when its target is // when its target differs from the currently active mode (you tag INTO the mode you're not in).
// the OPPOSITE of the currently active mode. When Design is active the two "…: Arrange" buttons
// are live and the two "…: Design" buttons are dead (already there); when Arrange is active the
// reverse. This module owns that one decision — (active mode, button target) -> live/disabled —
// as a pure predicate, unit-tested for both active modes; the shell reads the active mode from
// view().activeModeId() (the SAME source the footer toggle reads — one source of truth for
// "which mode is active") and draws the disabled buttons in the kit Disabled state.
//
// Why pure: which button is live is a decision, not a draw or a DAW behaviour. Keeping it here
// means the shell cannot drift the enablement from the rule, and both active modes are covered
// by CTest, not only whichever one a manual DAW pass happened to sit in.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include <string> #include <string>
namespace reasampler::ui { namespace reasampler::ui {
// A tag button's TARGET mode — the mode it sends the selection to when fired. Arrange = the // A tag button's TARGET mode. The Item/Track axis is orthogonal to enablement (both buttons for
// untagged default (returning the selection to Arrange), Design = tagged into the Design mode. // a target enable/disable together), so it isn't modelled here — the shell carries it per button.
// The Item/Track axis is orthogonal to enablement (both Item and Track buttons for a target
// enable/disable together), so it is NOT modelled here — the shell carries it per button.
enum class TagTarget { enum class TagTarget {
Arrange, Arrange,
Design, Design,
}; };
// True iff a tag button whose target is `target` should be LIVE (clickable), given the active // True iff a button targeting `target` should be live, given the active mode id `activeModeId`
// mode id `activeModeId` (as returned by ViewModeModel::activeModeId() — the mode ids are the // (ViewModeModel::activeModeId(), i.e. kArrangeModeId / kDesignModeId). An unrecognized active id
// pure `kArrangeModeId` / `kDesignModeId` constants). The rule: a button is live iff its target // leaves every button live (fail-open — never silently disable a reachable action).
// differs from the active mode — you tag INTO the mode you are not currently in. An unrecognized
// active id (neither arrange nor design) leaves every button live (fail-open: never silently
// disable an action the user can still reach), so a future added mode never dead-locks the bar.
bool tagButtonEnabled(const std::string& activeModeId, TagTarget target); bool tagButtonEnabled(const std::string& activeModeId, TagTarget target);
} // namespace reasampler::ui } // namespace reasampler::ui
+3 -4
View File
@@ -1,4 +1,4 @@
// overflow_menu — pure implementation. See overflow_menu.h. NO REAPER / SWELL / LICE / vendor. // overflow_menu — pure implementation. See overflow_menu.h.
#include "core/ui/overflow_menu.h" #include "core/ui/overflow_menu.h"
@@ -6,8 +6,7 @@ namespace reasampler::ui {
int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) { int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) {
if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return 0; if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return 0;
// The reserve is the button width plus a right gap (rightInset) and a matching left gap // Button width plus a right gap and a matching left gap for breathing room.
// (also rightInset) so the frequent buttons have breathing room before the menu button.
return spec.buttonWidth + 2 * spec.rightInset; return spec.buttonWidth + 2 * spec.rightInset;
} }
@@ -21,7 +20,7 @@ MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& s
int top = bar.y + spec.verticalInset; int top = bar.y + spec.verticalInset;
int height = bar.height - 2 * spec.verticalInset; int height = bar.height - 2 * spec.verticalInset;
if (height <= 0) { // thin band: clamp to the band's own extents rather than go negative if (height <= 0) {
top = bar.y; top = bar.y;
height = bar.height; height = bar.height;
} }
+19 -47
View File
@@ -1,44 +1,23 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// overflow_menu — the REAPER-free layout math behind the bank_panel TOP toolbar's "⋯ / More" // overflow_menu — layout for the bank_panel top toolbar's "..." overflow-menu button: the rare
// overflow-menu button (Phase L, L5, refinement 1). The rare capture variants (Batch Items / // capture variants (Batch Items / Batch Razor / Capture RT) live in a popup opened by a small
// Batch Razor / Capture RT) move OFF the always-visible top bar into a popup opened by a small // square button right-anchored in the top toolbar band. Owns the button's placement and the
// square button pinned to the FAR RIGHT of the top toolbar band. This module owns two things, // horizontal reserve action_bar must leave so its buttons never run under it. The popup itself
// both unit-tested outside the DAW: // (TrackPopupMenu) and command dispatch are shell concerns.
// * WHERE the More button sits in the top toolbar band (right-anchored, vertically inset);
// * the horizontal RESERVE the action_bar must leave for it, so the frequent buttons never
// run under the menu button (the shell shrinks the action_bar's usable width by this).
// The popup itself (TrackPopupMenu) + the command dispatch is shell — a transient OS menu, not
// panel chrome (brief §1: "a REAPER/host popup menu is acceptable"). Only the button
// geometry + hit-test live here.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
// Mirror of prune_button / mode_switch. The bar rect type it consumes mirrors action_bar's
// ActionBarRect shape but is named distinctly to avoid coupling the two modules.
namespace reasampler::ui { namespace reasampler::ui {
// The toolbar band the button is drawn into, top-left origin (SWELL/LICE convention). The // The toolbar band the button draws into, top-left origin.
// shell derives this from topToolbarRect(). A distinct type from action_bar::ActionBarRect so using MenuBarRect = Rect;
// this module stands alone (same shape; deliberate — the two modules are not coupled).
using MenuBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// The More button's pixel rectangle within the band, top-left origin. A zero-area rect // The More button's rect. Zero-area means "no button" — the three variants stay reachable via
// (width <= 0 or height <= 0) means "no button" — the band is degenerate or too narrow to // their bindable commands regardless.
// place the button clear of its left inset; the caller must not draw or hit-test it. The using MenuButtonRect = Rect;
// three variants stay reachable via their bindable commands, so a suppressed button is
// graceful, not a lost affordance.
using MenuButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Layout inputs for the More button, in pixels. Defaults match the bank_panel top-toolbar // Layout inputs, in pixels; defaults match the bank_panel top-toolbar metrics.
// metrics; the shell passes its own so draw and hit-test share one source of truth. // * minLeftInset — button's left edge must stay at least this far from the band's left edge;
// * buttonWidth — the button's fixed width (a compact square-ish glyph button). // otherwise computeMenuButton suppresses it (empty rect).
// * rightInset — gap from the band's right edge to the button's right edge.
// * verticalInset — top/bottom gap inside the band (shorter than the band so it reads as a
// raised control, matching the action_bar buttons' verticalInset).
// * minLeftInset — the button's left edge must stay at least this far from the band left
// edge; if it would encroach past this, computeMenuButton yields an empty
// rect (button suppressed).
struct MenuButtonSpec { struct MenuButtonSpec {
int buttonWidth = 28; int buttonWidth = 28;
int rightInset = 6; int rightInset = 6;
@@ -46,23 +25,16 @@ struct MenuButtonSpec {
int minLeftInset = 40; int minLeftInset = 40;
}; };
// The horizontal reserve (px) the action_bar must leave at the band's right so its buttons // Horizontal reserve (px) action_bar must leave at the band's right: button width + both insets.
// never run under the More button: the button width + both insets (right gap + a matching // 0 for a degenerate band.
// left breathing gap equal to rightInset). The shell subtracts this from the action_bar rect's
// width before laying out slots. Returns 0 for a degenerate band (nothing to reserve).
int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec); int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec);
// Computes the More button's rect within `bar` per `spec`. Right-anchored: the button's right // The More button's rect within `bar`, right-anchored, vertically centred by verticalInset.
// edge is bar.x + bar.width - rightInset, its width is buttonWidth, vertically centred by // Empty when the band is degenerate, buttonWidth <= 0, or the left edge would fall closer to the
// verticalInset. Returns an EMPTY rect when: the band is degenerate (width/height <= 0), the // band's left than minLeftInset. A thin band clamps height to the band's own rather than negative.
// buttonWidth is non-positive, OR the resulting left edge would fall closer to the band left
// than minLeftInset. A thin band clamps the button height to the band's own rather than going
// negative (mirror of computePruneButton).
MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec); MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec);
// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`. // Half-open bounds, matching computeMenuButton. Empty button claims nothing.
// Half-open bounds [x, x+width) x [y, y+height) — matches computeMenuButton so draw and
// hit-test agree on the same pixels. An empty button never claims a point (always false).
bool hitTestMenuButton(int px, int py, const MenuButtonRect& button); bool hitTestMenuButton(int px, int py, const MenuButtonRect& button);
} // namespace reasampler::ui } // namespace reasampler::ui
+1 -4
View File
@@ -1,9 +1,6 @@
#include "core/ui/prune_button.h" #include "core/ui/prune_button.h"
// prune_button implementation — right-anchored button placement in the footer strip, // prune_button — pure implementation. See prune_button.h.
// with a left-collision suppression rule. Trivially auditable arithmetic; the safety
// property (a suppressed/empty button never claims a click) is a pure predicate tested
// outside the DAW.
namespace reasampler::ui { namespace reasampler::ui {
+20 -64
View File
@@ -1,82 +1,38 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// prune_button — the REAPER-free layout math behind the bank_panel's Prune button // prune_button — layout for the bank_panel's Prune button in the tail-footer strip. Panel shell
// (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's // owns SWELL/LICE/dispatch; this owns whether a click lands on it.
// tail-footer strip that fires the "Prune bank folder" command. The panel shell
// (shell/panel/) owns the SWELL window, LICE drawing, and the Main_OnCommand
// dispatch of the registered command id — all REAPER-bound, DAW-verified. What is
// NOT DAW-bound — WHERE the button sits in the footer and whether a click lands on
// it — lives here so it is unit-tested outside the DAW (CLAUDE.md §load-bearing
// split). Mirror of mode_switch / tab_strip.
// //
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library // Placement: right-anchored in the footer, inset from the right edge, just left of the version
// only. Builds and unit-tests without REAPER. // readout, set apart from the footer_bar left group (mode toggle / count / Tail). Suppressed
// // (empty rect) rather than drawn overlapping when the footer is too narrow — the command stays
// -- Placement contract -------------------------------------------------------- // reachable via its binding either way.
//
// The footer hosts (L4) a LEFT group — the [Arrange|Design] mode toggle, a per-mode
// count, and the Tail button (bank_panel footer_bar) — and a RIGHT-aligned version
// readout (bank_panel drawFooter). The prune button is a fixed-width button anchored
// to the RIGHT of the footer, inset from the right edge, sitting just LEFT of the
// version readout's inset region and set APART from the benign left group. It never
// overlaps the left group (footer_bar reserves rightReserve px at the right to match).
// When the footer is too narrow to fit the button without colliding with the left
// inset, the button is suppressed (empty rect) rather than drawn on top — the action
// is always reachable via its bindable command, so a hidden button is a graceful
// degradation, not a lost affordance.
namespace reasampler::ui { namespace reasampler::ui {
// The footer strip the button is drawn into, top-left origin (SWELL/LICE using FooterRect = Rect;
// convention). (x, y) is the top-left corner; width/height are the strip extents. using ButtonRect = Rect;
// bank_panel derives this from panelFooter() and passes it here.
using FooterRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// A button's pixel rectangle within the footer, top-left origin. A zero-area rect // Layout inputs, in pixels; defaults match the bank_panel footer metrics.
// (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place // * rightInset — gap from the footer's right edge to the button's right edge, clearing the
// it, or the footer itself is degenerate; the caller must not draw or hit-test it. // right-aligned version readout. COUPLED to drawFooter's version-readout
using ButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased // margin (panel_render.cpp) and to FooterBarSpec::rightReserve, which must
// exceed rightInset + buttonWidth so the left group never runs under this
// Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer // button. Update together if either margin changes.
// metrics; the shell passes its own so draw and hit-test share one source of truth. // * minLeftInset — button's left edge must stay this far from the footer left edge (room for
// * buttonWidth — the button's fixed width. // the footer-left group); otherwise the button is suppressed.
// * rightInset — gap from the footer's right edge to the button's right edge (the
// button sits left of this inset, clearing the right-aligned version
// readout). COUPLED TO drawFooter (panel_render.cpp): the version readout
// uses an 8 px right margin. The button's right edge lands at
// footer.right - 84, i.e. 76 px left of the readout's right margin —
// enough clearance for the ~10-char label. ALSO COUPLED to
// FooterBarSpec::rightReserve (footer_bar.h): the L4 footer-left group
// (mode toggle + count + Tail) reserves that many px at the right so it
// never runs under this button; rightReserve must exceed rightInset +
// buttonWidth. If the version readout's inset changes in drawFooter,
// update this value to maintain clearance.
// * verticalInset — top/bottom gap inside the footer (the button is shorter than the
// strip so it reads as a raised control, not a full-height fill).
// * minLeftInset — the button's left edge must stay at least this far from the footer
// left edge (reserving room for the L4 footer-left group). If the button
// would encroach past this, computePruneButton yields an empty rect
// (button suppressed — see header placement contract).
struct PruneButtonSpec { struct PruneButtonSpec {
int buttonWidth = 72; int buttonWidth = 72;
int rightInset = 84; // COUPLED: version readout in drawFooter uses an 8 px right margin int rightInset = 84;
int verticalInset = 4; int verticalInset = 4;
int minLeftInset = 120; int minLeftInset = 120;
}; };
// Computes the prune button's rect within `footer` per `spec`. Right-anchored: the // Right-anchored rect within `footer`, vertically centred. Empty when the footer is degenerate
// button's right edge is footer.x + footer.width - rightInset, its width is buttonWidth, // or the resulting left edge would fall closer to the footer's left than minLeftInset.
// and it is vertically centred by verticalInset. Returns an EMPTY rect (button
// suppressed) when: the footer is degenerate (width/height <= 0), OR the resulting left
// edge would fall closer to the footer left than minLeftInset (too narrow to place
// without colliding with the tail label). The action stays reachable via its command in
// that case — a suppressed button is graceful, not a lost feature.
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec); ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec);
// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`. // Half-open bounds, matching computePruneButton. Empty button never claims a point.
// Half-open bounds [x, x+width) x [y, y+height) — matches computePruneButton so draw and
// hit-test agree on the same pixels. An empty button never claims a point (always false),
// so a suppressed button cannot be accidentally clicked.
bool hitTestPruneButton(int px, int py, const ButtonRect& button); bool hitTestPruneButton(int px, int py, const ButtonRect& button);
} // namespace reasampler::ui } // namespace reasampler::ui
+6 -26
View File
@@ -1,23 +1,6 @@
#pragma once #pragma once
// rect.h — the ONE concrete pixel rectangle (Q-W1, T2-05 ≡ T4-21). // rect.h — the one concrete pixel rectangle. XYWH storage, half-open on both axes: a rect
// // covers [x, x+width) x [y, y+height) — matches the LICE/SWELL RECT convention.
// Before Q-W1 the codebase carried 12+ byte-identical {x, y, width, height} structs
// (ButtonRect / FooterRect / CellRect / KitBox / ...) plus a second LTRB grammar on
// the VST side (editor_geometry's left/top/right/bottom Rect). This is the single
// owner: one CONCRETE type (deliberately NOT a template — the role types differed in
// name only, so a template would model nothing), with per-role aliases at the old
// definition sites so call sites keep their semantic names
// (`using ButtonRect = ui::Rect;`).
//
// Grammar: XYWH storage (the majority grammar — every extension role struct), with
// right()/bottom() accessors and an ltrb() factory so the former LTRB call sites
// convert mechanically. Half-open on both axes: a rect covers
// [x, x+width) × [y, y+height) — the same convention LICE/SWELL RECTs use, and the
// one every hitTest* in the codebase already implements.
//
// PURE MODULE: standard library only. Header-only; behavior is covered by the role
// modules' own test executables (prune_button / footer_bar / bank_grid / ... and the
// instrument-ui suites), which exercise every alias against these semantics.
namespace reasampler::ui { namespace reasampler::ui {
@@ -27,16 +10,13 @@ struct Rect {
int width = 0; int width = 0;
int height = 0; int height = 0;
// Exclusive edges (half-open convention).
int right() const { return x + width; } int right() const { return x + width; }
int bottom() const { return y + height; } int bottom() const { return y + height; }
// A zero-or-negative-area rect means "not placed / suppressed": the caller must // Zero-or-negative area means "not placed / suppressed" caller must not draw or hit-test it.
// not draw or hit-test it (the shared graceful-degradation contract).
bool empty() const { return width <= 0 || height <= 0; } bool empty() const { return width <= 0 || height <= 0; }
// The former LTRB grammar's constructor (editor_geometry and friends): edges in, // LTRB constructor for call sites that think in edges rather than extents.
// extents stored. right/bottom exclusive, matching right()/bottom().
static Rect ltrb(int left, int top, int right, int bottom) { static Rect ltrb(int left, int top, int right, int bottom) {
return Rect{left, top, right - left, bottom - top}; return Rect{left, top, right - left, bottom - top};
} }
@@ -47,8 +27,8 @@ struct Rect {
bool operator!=(const Rect& o) const { return !(*this == o); } bool operator!=(const Rect& o) const { return !(*this == o); }
}; };
// True iff (px, py) falls inside r under the half-open convention. An empty rect // Half-open containment; an empty rect contains nothing, so a suppressed affordance never
// contains nothing, so a suppressed affordance can never claim a click. // claims a click.
inline bool contains(const Rect& r, int px, int py) { inline bool contains(const Rect& r, int px, int py) {
return px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height; return px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height;
} }
+8 -17
View File
@@ -1,4 +1,4 @@
// tab_strip — pure implementation. See tab_strip.h. NO REAPER / SWELL / vendor. // tab_strip — pure implementation. See tab_strip.h.
#include "core/ui/tab_strip.h" #include "core/ui/tab_strip.h"
@@ -8,17 +8,16 @@ namespace reasampler::ui {
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset) { const TabStripSpec& spec, int scrollOffset) {
(void)scrollOffset; // layout depends on geometry only, not the current offset (void)scrollOffset; // layout depends on geometry only
TabStripLayout out; TabStripLayout out;
if (tabCount <= 0 || strip.width <= 0) { if (tabCount <= 0 || strip.width <= 0) {
out.trackX = strip.x; out.trackX = strip.x;
out.trackWidth = strip.width > 0 ? strip.width : 0; out.trackWidth = strip.width > 0 ? strip.width : 0;
return out; // nothing to lay out: track == strip, no overflow, no chevrons return out;
} }
const int totalTabsWidth = tabCount * spec.tabWidth; const int totalTabsWidth = tabCount * spec.tabWidth;
if (totalTabsWidth <= strip.width) { if (totalTabsWidth <= strip.width) {
// Everything fits: the whole strip is the track; no chevrons, no scroll.
out.overflow = false; out.overflow = false;
out.trackX = strip.x; out.trackX = strip.x;
out.trackWidth = strip.width; out.trackWidth = strip.width;
@@ -26,15 +25,12 @@ TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
return out; return out;
} }
// Overflow: reserve a chevron band at each end; the tabs live between them.
out.overflow = true; out.overflow = true;
out.leftChevron = true; out.leftChevron = true;
out.rightChevron = true; out.rightChevron = true;
out.trackX = strip.x + spec.chevronWidth; out.trackX = strip.x + spec.chevronWidth;
out.trackWidth = strip.width - 2 * spec.chevronWidth; out.trackWidth = strip.width - 2 * spec.chevronWidth;
if (out.trackWidth < 0) out.trackWidth = 0; 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; out.maxScroll = totalTabsWidth - out.trackWidth;
if (out.maxScroll < 0) out.maxScroll = 0; if (out.maxScroll < 0) out.maxScroll = 0;
return out; return out;
@@ -61,11 +57,9 @@ std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
for (int i = 0; i < tabCount; ++i) { for (int i = 0; i < tabCount; ++i) {
const int rawLeft = trackLeft + i * spec.tabWidth - offset; const int rawLeft = trackLeft + i * spec.tabWidth - offset;
const int rawRight = rawLeft + spec.tabWidth; 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 left = rawLeft < trackLeft ? trackLeft : rawLeft;
int right = rawRight > trackRight ? trackRight : rawRight; int right = rawRight > trackRight ? trackRight : rawRight;
if (right <= left) continue; // fully scrolled out of view either side if (right <= left) continue; // fully scrolled out of view
TabRect r; TabRect r;
r.index = i; r.index = i;
r.x = left; r.x = left;
@@ -79,10 +73,9 @@ std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset) { const TabStripSpec& spec, int scrollOffset) {
TabHit miss; // {None, -1} TabHit miss;
if (tabCount <= 0 || strip.width <= 0 || strip.height <= 0) return miss; 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 || if (px < strip.x || px >= strip.x + strip.width ||
py < strip.y || py >= strip.y + strip.height) py < strip.y || py >= strip.y + strip.height)
return miss; return miss;
@@ -90,8 +83,7 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
const TabStripLayout layout = const TabStripLayout layout =
computeTabStripLayout(strip, tabCount, spec, scrollOffset); computeTabStripLayout(strip, tabCount, spec, scrollOffset);
// Chevrons take precedence at the strip ends: a click in a reserved chevron band // Chevron bands take precedence at the strip ends over any tab.
// is a scroll, never a tab (the tab track excludes those bands).
if (layout.overflow) { if (layout.overflow) {
if (px < strip.x + spec.chevronWidth) if (px < strip.x + spec.chevronWidth)
return TabHit{TabHitKind::ScrollLeft, -1}; return TabHit{TabHitKind::ScrollLeft, -1};
@@ -99,14 +91,13 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
return TabHit{TabHitKind::ScrollRight, -1}; return TabHit{TabHitKind::ScrollRight, -1};
} }
// Inside the track: find the visible tab whose clipped rect contains px. Reuse // Reuse computeTabRects so the hit matches exactly what was drawn (clipping included).
// computeTabRects so the hit matches exactly what was drawn (clipping included).
const std::vector<TabRect> rects = const std::vector<TabRect> rects =
computeTabRects(strip, tabCount, spec, scrollOffset); computeTabRects(strip, tabCount, spec, scrollOffset);
for (const TabRect& r : rects) { for (const TabRect& r : rects) {
if (px >= r.x && px < r.x + r.width) return TabHit{TabHitKind::Tab, r.index}; 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) return miss;
} }
} // namespace reasampler::ui } // namespace reasampler::ui
+32 -74
View File
@@ -1,45 +1,25 @@
#pragma once #pragma once
#include "core/ui/rect.h" #include "core/ui/rect.h"
// tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's // tab_strip — layout + hit-test for the bank_panel's named-banks tab strip: a LICE-drawn strip
// named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the // (not a SWELL tab control) that scrolls via chevrons when tabs overflow the strip width.
// 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 (shell/panel/) 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> #include <vector>
namespace reasampler::ui { namespace reasampler::ui {
// The strip the tabs are drawn into, top-left origin (SWELL/LICE convention). // The strip the tabs draw into, top-left origin.
// (x, y) is the top-left corner; width/height are the strip extents. The panel using TabStripRect = Rect;
// reserves this as a fixed-height band at the top of the named-banks region.
using TabStripRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed // tabWidth is fixed per tab so the strip reads as a uniform segmented control and overflow math
// so the strip reads as a uniform segmented control and overflow math stays simple — // stays simple (labels ellipsize, they don't resize the tab). chevronWidth is reserved at each
// labels ellipsize within the tab, they do not resize it). chevronWidth is the width // end only when tabs overflow.
// 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 { struct TabStripSpec {
int tabWidth = 96; int tabWidth = 96;
int chevronWidth = 20; int chevronWidth = 20;
}; };
// One tab's pixel rectangle within the strip, top-left origin, ALREADY translated // One tab's rect, already translated by scroll offset and clipped to the visible track. A tab
// by the current scroll offset and clipped to the visible track. `index` is the // scrolled fully out of view is omitted from computeTabRects's result.
// 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 { struct TabRect {
int index = 0; int index = 0;
int x = 0; int x = 0;
@@ -53,59 +33,41 @@ struct TabRect {
} }
}; };
// The scrollable track's geometry: where the tabs may be drawn (between the // Scrollable track geometry, shared by layout + hit-test so both agree.
// 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 { struct TabStripLayout {
bool overflow = false; // true iff N tabs at tabWidth exceed the track width bool overflow = false;
int trackX = 0; // left edge of the tab track (past the left chevron) int trackX = 0;
int trackWidth = 0; // width available to tabs (strip minus both chevrons) int trackWidth = 0;
int maxScroll = 0; // largest valid scroll offset (0 when no overflow) int maxScroll = 0;
bool leftChevron = false; // a left-scroll affordance is reserved this frame bool leftChevron = false;
bool rightChevron = false;// a right-scroll affordance is reserved this frame bool rightChevron = false;
}; };
// Computes the strip layout for `tabCount` tabs of `spec.tabWidth` in `strip`, // Layout for `tabCount` tabs of `spec.tabWidth` in `strip`. No overflow: track == strip, no
// given the current `scrollOffset`. Pure geometry: // chevrons, maxScroll 0. Overflow: both chevrons always reserved together (simpler than hiding
// * No overflow (all tabs fit the strip width): overflow=false, no chevrons, the // one at a scroll limit — a chevron click there is a harmless no-op the shell clamps); track is
// track IS the strip, maxScroll=0. // the strip minus both chevrons; maxScroll is how far the tab run exceeds the track.
// * Overflow: both chevrons are reserved (chevronWidth each), the track is the // tabCount <= 0 or non-positive strip width returns a zeroed layout.
// 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, TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset); const TabStripSpec& spec, int scrollOffset);
// Clamps a desired scroll offset into [0, maxScroll] for the given layout. The shell // Clamps a desired scroll offset into [0, maxScroll]; always 0 when tabs fit.
// 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); int clampTabScroll(int desiredOffset, const TabStripLayout& layout);
// Tiles `tabCount` fixed-width tabs left-to-right into the layout's track, shifted // Tiles tabCount fixed-width tabs into the track, shifted by scrollOffset, returning only
// left by `scrollOffset`, and returns the rects that are at least partially visible // partially-or-fully visible rects (clipped to the track so a scrolled tab never draws under a
// (in tab-index order). Each tab i sits at trackX + i*tabWidth - scrollOffset; a tab // chevron). Caller must pass the same scrollOffset used for computeTabStripLayout.
// 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, std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset); const TabStripSpec& spec, int scrollOffset);
// What a point in the strip resolves to.
enum class TabHitKind { enum class TabHitKind {
None, // outside the strip, or in dead space between visible tabs None,
Tab, // a tab — `index` is the tab's index in the caller's list Tab,
ScrollLeft, // the left overflow chevron ScrollLeft,
ScrollRight, // the right overflow chevron ScrollRight,
}; };
// The outcome of hit-testing a point against the strip. For Tab, `index` is the tab // index is the tab's index for Tab, -1 for chevrons/None.
// index; for the chevrons and None it is -1.
struct TabHit { struct TabHit {
TabHitKind kind = TabHitKind::None; TabHitKind kind = TabHitKind::None;
int index = -1; int index = -1;
@@ -115,12 +77,8 @@ struct TabHit {
} }
}; };
// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out // Hit-tests a point against the strip laid out for `tabCount` tabs at `scrollOffset`. Chevrons
// for `tabCount` tabs at `scrollOffset`. Chevrons take precedence over tabs at the // take precedence at the strip ends. Half-open bounds match computeTabRects.
// 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, TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset); const TabStripSpec& spec, int scrollOffset);
+36 -56
View File
@@ -1,4 +1,4 @@
// theme — pure implementation. See theme.h. NO REAPER / SWELL / LICE / vendor. // theme — pure implementation. See theme.h.
#include "core/ui/theme.h" #include "core/ui/theme.h"
@@ -10,59 +10,48 @@ namespace reasampler::ui {
namespace { namespace {
// =========================================================================== // ===========================================================================
// THE ONE DIRECTION CONSTANTS BLOCK (DS-2 revised: B "Neon Console" REAPER-grey // THE ONE DIRECTION CONSTANTS BLOCK. Every role color below is one of these constants;
// neutrals + three-accent pastel system + C pastel spectral). // roleColor() is a pure switch over them — this is the single point of change for the
// // visual direction. Values are locked against each WCAG floor (proven by test_theme.cpp):
// This is the SINGLE POINT OF CHANGE. Every role color below is one of these // text/dim is lifted to the lightest grey that still clears AA 4.5:1 body on the greyest
// constants; roleColor() is a pure switch over them. To re-pick the visual // surface it draws on; each pastel accent is the softest tint that still clears the 3:1
// direction (§4: A Studio Rack / B Neon Console / C full spectral), edit THIS // indicator floor on bg/cell ("punch from the soft side").
// block — no shell, no other module, names a color. Values are locked against
// each WCAG floor (proven by test_theme.cpp): text/dim is lifted to the lightest
// grey that still clears AA 4.5:1 body on the greyest surface it draws on; each
// pastel accent is the softest tint that still clears the 3:1 indicator floor on
// bg/cell ("punch from the soft side" — DS-2 revised §2.1 grey re-read).
// =========================================================================== // ===========================================================================
// REAPER-theme mid-grey elevation stack (DS-2 revised — NOT near-black). Matches // REAPER-theme mid-grey elevation stack, matching Daniel's REAPER theme so the dock reads as
// Daniel's REAPER theme so the dock reads as part of REAPER: base = window chrome // part of REAPER: base = window chrome grey, panel/cell one step lighter each. Elevation-ladder
// grey, panel/cell one step lighter each. The elevation-ladder discipline is // discipline: base < panel < cell by a few %, micro-gradient + inner highlight/shadow carry
// unchanged (base < panel < cell by a few %, micro-gradient + inner highlight/ // elevation, not hard borders.
// shadow carry elevation, not hard borders); only the VALUES moved up into grey.
constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey
constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter
constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg
constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle step above cell constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle step above cell
// Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer // Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer grey
// grey secondary. The greyer surfaces shrank the dim cushion (mid-grey-on-mid-grey // secondary. Mid-grey-on-mid-grey is the classic AA failure: the spec-start #a0a0a0 lands
// is the classic AA failure): the spec-start #a0a0a0 lands ~4.35:1 on bg/cell, UNDER // ~4.35:1 on bg/cell, under the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1), the lightest
// the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1 on bg/cell), the lightest grey // grey that still reads dim while clearing the floor. Locked by test_theme.cpp.
// that still reads dim while clearing AA 4.5 body on the greyest surface it draws
// body text on. Locked by test_theme.cpp.
constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc
constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0) constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0)
// The three-accent pastel system (DS-2 revised — replaces the single electric cyan). // Three-accent pastel system: primary = pastel lime (the live/active/selected signal);
// primary = pastel lime (the live/active/selected signal, the eye-magnet); secondary // secondary = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never
// = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never // intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On bg/cell the
// intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On the // pastels clear the 3:1 indicator floor comfortably at these values (primary ~7.6, secondary
// greyer bg/cell the pastels clear the 3:1 indicator floor comfortably (primary ~7.6, // ~6.8, tertiary ~5.5), so no per-hue nudge was needed. warn is reserved for byte-deleting
// secondary ~6.8, tertiary ~5.5) at the spec-start values, so no per-hue nudge was // states only.
// needed — the hues stay pastel lime/teal/purple. warn is a reserved red/amber for
// byte-deleting states only.
constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime
constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal
constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple
constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime
constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only
// Direction C pastel spectral ramp (DS-2 revised): a three-stop sweep through the // Spectral ramp: pastel lime (low) -> pastel teal (mid) -> pastel purple (high). Endpoints and
// accents — pastel lime (low) -> pastel teal (mid) -> pastel purple (high) — so the // midpoint ARE the three accent constants (single source), so the keyboard strip reads as an
// signature keyboard strip reads as an extension of the accent system, not a neon // extension of the accent system.
// flourish. Endpoints/midpoint ARE the three accent constants (single source). constexpr KitColor kDirSpectralLo = kDirAccentPrimary;
constexpr KitColor kDirSpectralLo = kDirAccentPrimary; // low notes: pastel lime constexpr KitColor kDirSpectralMid = kDirAccentSecondary;
constexpr KitColor kDirSpectralMid = kDirAccentSecondary; // mid notes: pastel teal constexpr KitColor kDirSpectralHi = kDirAccentTertiary;
constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // high notes: pastel purple
// --- state transform helpers ------------------------------------------------- // --- state transform helpers -------------------------------------------------
@@ -70,8 +59,8 @@ std::uint8_t clamp8(int v) {
return static_cast<std::uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v)); return static_cast<std::uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v));
} }
// Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state // Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state tint changes
// tint changes hue/brightness, not opacity; disabled handles alpha separately). // hue/brightness, not opacity; disabled handles alpha separately).
KitColor mix(const KitColor& a, const KitColor& b, double t) { KitColor mix(const KitColor& a, const KitColor& b, double t) {
return KitColor{ return KitColor{
clamp8(static_cast<int>(std::lround(a.r + (b.r - a.r) * t))), clamp8(static_cast<int>(std::lround(a.r + (b.r - a.r) * t))),
@@ -81,7 +70,6 @@ KitColor mix(const KitColor& a, const KitColor& b, double t) {
}; };
} }
// Scale RGB by factor (brightness up/down), alpha untouched.
KitColor scale(const KitColor& c, double factor) { KitColor scale(const KitColor& c, double factor) {
return KitColor{ return KitColor{
clamp8(static_cast<int>(std::lround(c.r * factor))), clamp8(static_cast<int>(std::lround(c.r * factor))),
@@ -93,7 +81,6 @@ KitColor scale(const KitColor& c, double factor) {
// Desaturate toward the color's own luminance-gray by amount in [0, 1]. // Desaturate toward the color's own luminance-gray by amount in [0, 1].
KitColor desaturate(const KitColor& c, double amount) { KitColor desaturate(const KitColor& c, double amount) {
// 8-bit gray from the perceptual weights (same weighting family as luminance).
const int gray = clamp8(static_cast<int>( const int gray = clamp8(static_cast<int>(
std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b))); std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b)));
const KitColor g{static_cast<std::uint8_t>(gray), const KitColor g{static_cast<std::uint8_t>(gray),
@@ -132,25 +119,20 @@ KitColor roleColorState(Role role, InteractionState state) {
case InteractionState::Rest: case InteractionState::Rest:
return base; return base;
case InteractionState::Hover: case InteractionState::Hover:
// Lighten the surface toward the hot accent (~10%) — the "alive" cue. // Lighten toward the hot accent (~10%) — the "alive" cue.
return mix(base, roleColor(Role::AccentHot), 0.10); return mix(base, roleColor(Role::AccentHot), 0.10);
case InteractionState::Active: case InteractionState::Active:
// The selected/active layer carries the PRIMARY accent — "this is live" // "This is live" is always the primary hue — secondary/tertiary stay categorical.
// is always the primary hue (DS-2 revised: primary leads state; secondary/
// tertiary are categorical, never intensity).
return roleColor(Role::AccentPrimary); return roleColor(Role::AccentPrimary);
case InteractionState::Pressed: case InteractionState::Pressed:
// The surface "pushes in": darken. return scale(base, 0.82); // the surface "pushes in"
return scale(base, 0.82);
case InteractionState::Dragging: case InteractionState::Dragging:
// A live-drag element reads as active-but-lighter (primary -> hot).
return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30); return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30);
case InteractionState::Focus: case InteractionState::Focus:
// Focus keeps the surface but is drawn with a text/primary ring by the // Focus keeps the surface; the shell draws a text/primary ring on top, and the
// shell; the fill nudges toward the primary accent so focus reads pre-ring. // fill nudges toward the primary accent so focus reads pre-ring.
return mix(base, roleColor(Role::AccentPrimary), 0.08); return mix(base, roleColor(Role::AccentPrimary), 0.08);
case InteractionState::Disabled: { case InteractionState::Disabled: {
// Desaturate and drop alpha to 40% (§3.3).
KitColor d = desaturate(base, 0.6); KitColor d = desaturate(base, 0.6);
d.a = static_cast<std::uint8_t>(std::lround(base.a * 0.4)); d.a = static_cast<std::uint8_t>(std::lround(base.a * 0.4));
return d; return d;
@@ -162,10 +144,8 @@ KitColor roleColorState(Role role, InteractionState state) {
KitColor spectralColor(double t) { KitColor spectralColor(double t) {
if (t < 0.0) t = 0.0; if (t < 0.0) t = 0.0;
if (t > 1.0) t = 1.0; if (t > 1.0) t = 1.0;
// Three-stop pastel sweep anchored on the accent trio (DS-2 revised Direction C): // Interpolate each half separately so the midpoint IS the secondary accent (a single
// lime (low) -> teal (mid, t=0.5) -> purple (high). A single Lo->Hi lerp would skip // Lo->Hi lerp would skip it and drift the ramp off the accent family).
// the teal midpoint and drift the ramp off the accent family; interpolate each half
// so the midpoint IS the secondary accent and every stop stays in the pastel band.
if (t <= 0.5) { if (t <= 0.5) {
return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5); return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5);
} }
+32 -59
View File
@@ -1,35 +1,21 @@
#pragma once #pragma once
// theme — the REAPER-free, LICE-free palette + type-scale core of the shared drawing // theme — the palette + type-scale core of the shared drawing kit: a ROLE-based color model
// kit (Phase L, L1). This is the "one source of drawing" made testable at its root: a // (bg/base, bg/panel, bg/cell, line/hairline, text/primary, text/dim, accent/primary,
// ROLE-based color model (bg/base, bg/panel, bg/cell, line/hairline, text/primary, // accent/secondary, accent/tertiary, accent/hot, warn), an interaction-state model
// text/dim, accent/primary, accent/secondary, accent/tertiary, accent/hot, warn), an // (rest/hover/active/pressed/dragging/focus/disabled), and the WCAG contrast math that lets a
// INTERACTION-STATE model (rest/hover/active/pressed/dragging/focus/disabled), and the // unit test prove every text-on-surface pair clears its floor.
// WCAG contrast math that lets a unit test prove every text-on-surface pair clears its
// floor ("punch to the floor, not past it").
// //
// THE SINGLE POINT OF CHANGE (DS-2 revised): every role color is produced by roleColor() // Every role color is produced by roleColor() from ONE direction constants block (theme.cpp) —
// from ONE direction constants block (kDirection*, below) carrying the settled B (Neon // the single point of change; no shell hardcodes a color, it asks by role. The spectral hue ramp
// Console) neutrals — now REAPER-theme mid-grey, not near-black — plus the three-accent // (spectralColor) lives here too so the keyboard strip derives its per-note hue from the same
// pastel system (primary lime / secondary teal / tertiary purple) and the C pastel // source, anchored on the three accents (primary -> secondary -> tertiary).
// spectral ramp. Switching the visual direction is editing that block and nothing else —
// no shell hardcodes a color; the shell asks the theme by role. The spectral (Direction C)
// hue ramp lives here too (spectralColor) so the signature keyboard strip's L3 consumer
// derives its per-note hue from the same source (a pastel sweep anchored on the three
// accents: primary lime -> secondary teal -> tertiary purple).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER. Mirror of mode_switch / bank_grid — the
// shell (draw_kit) turns a KitColor into a LICE_pixel at the boundary; the theme never
// names a LICE type.
#include <cstdint> #include <cstdint>
namespace reasampler::ui { namespace reasampler::ui {
// A straight 8-bit-per-channel RGBA color, LICE-free. The draw shell converts this to a // Straight 8-bit-per-channel RGBA, LICE-free; draw_kit converts to LICE_pixel at the boundary.
// LICE_pixel via LICE_RGBA at the boundary (draw_kit); nothing here depends on LICE's // Named "KitColor" (not "Color"/"RGBA") to avoid collision.
// packing. Deliberately NOT named "Color"/"RGBA" (both are common collision surfaces);
// "KitColor" scopes it to the kit.
struct KitColor { struct KitColor {
std::uint8_t r = 0; std::uint8_t r = 0;
std::uint8_t g = 0; std::uint8_t g = 0;
@@ -41,8 +27,7 @@ struct KitColor {
} }
}; };
// The structural palette roles (direction-independent — §2.1 of the design doc). The // Structural palette roles, direction-independent — the shell always asks by role.
// direction (B/C) sets the concrete hue behind each; the shell always asks by role.
enum class Role { enum class Role {
BgBase, // window canvas BgBase, // window canvas
BgPanel, // a raised region (list, waveform pane) BgPanel, // a raised region (list, waveform pane)
@@ -50,69 +35,57 @@ enum class Role {
LineHairline, // separators (used sparingly — elevation carries most separation) LineHairline, // separators (used sparingly — elevation carries most separation)
TextPrimary, // labels, values TextPrimary, // labels, values
TextDim, // secondary / units TextDim, // secondary / units
AccentPrimary, // the live / active / selected signal — where the punch lives (pastel lime) AccentPrimary, // live / active / selected — where the punch lives (pastel lime)
AccentSecondary,// categorical role A (pastel teal) — a distinct KIND, never intensity AccentSecondary,// categorical role A (pastel teal) — a distinct KIND, never intensity
AccentTertiary,// categorical role B (pastel purple) — a distinct KIND, never intensity AccentTertiary,// categorical role B (pastel purple) — a distinct KIND, never intensity
AccentHot, // hover / live / drag feedback (a brighter tint OF the primary accent) AccentHot, // hover / live / drag feedback (a brighter tint OF the primary accent)
Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states
}; };
// The interaction-state model every kit component honors (§3.3). A component draws its // Interaction-state model every kit component honors; stateShift (roleColorState) is the
// role surface transformed by its current state; stateShift() below is that transform. // role-surface transform for the current state.
enum class InteractionState { enum class InteractionState {
Rest, Rest,
Hover, Hover,
Active, // selected / active Active,
Pressed, Pressed,
Dragging, Dragging,
Focus, Focus,
Disabled, Disabled,
}; };
// Text size classes for the WCAG floor. "Large" text (>= ~18.66px, or >= ~14px bold) and // Text size classes for the WCAG floor: "Large" (>= ~18.66px, or >= ~14px bold) and UI-state
// UI-state indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA). The kit's // indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA).
// four cached fonts map onto these: title -> Large, label/value -> Body, micro -> Body.
enum class TextClass { enum class TextClass {
Body, // AA 4.5:1 Body, // AA 4.5:1
Large, // AA-large 3:1 (also the floor for state indicators) Large, // AA-large 3:1 (also the floor for state indicators)
}; };
// The concrete color for a role, produced from the ONE direction constants block. This is // The concrete color for a role, from the one direction constants block — the single choke
// the single choke point the "single point of change" guarantee rests on: the shell has // point re-picking the direction touches.
// no other way to obtain a palette color, so re-picking the direction is editing the
// kDirection* block this reads and nothing else.
KitColor roleColor(Role role); KitColor roleColor(Role role);
// The color for a role under an interaction state — roleColor(role) transformed by the // roleColor(role) transformed by state (hover lightens toward accent/hot, pressed darkens,
// state (hover lightens toward accent/hot, pressed darkens, disabled desaturates + drops // disabled desaturates + drops alpha, etc). Rest returns roleColor(role) unchanged.
// alpha, etc.). Surfaces use this so every component gets the whole state model for free.
// Rest returns roleColor(role) unchanged.
KitColor roleColorState(Role role, InteractionState state); KitColor roleColorState(Role role, InteractionState state);
// Direction C's spectral hue ramp (DS-2 revised — a PASTEL sweep anchored on the three // Spectral hue ramp for the keyboard strip: maps normalized position t in [0, 1] (low note ->
// accents, not the old neon cool-blue -> hot-magenta): maps a normalized position t in // high note) through accent/primary (low) -> accent/secondary (mid) -> accent/tertiary (high),
// [0, 1] (low note -> high note across the keyboard strip) to a color that runs // so the strip reads as an extension of the accent system rather than a separate flourish.
// accent/primary (pastel lime, low) -> accent/secondary (pastel teal, mid) -> // t is clamped to [0, 1].
// accent/tertiary (pastel purple, high). The same three hues that mean "live / category A
// / category B" elsewhere are the endpoints and midpoint here, so the strip reads as an
// extension of the accent system, not a separate flourish. The signature keyboard-strip
// surface (an L3 consumer) derives each note/zone's hue from this ONE function so the
// spectrum is defined in the same place as the rest of the palette. t is clamped to [0, 1].
KitColor spectralColor(double t); KitColor spectralColor(double t);
// --- WCAG contrast (the "punch" rule, made testable) -------------------------- // --- WCAG contrast (the "punch" rule, made testable) --------------------------
//
// The relative luminance of a color per WCAG 2.1 (sRGB linearization + the 0.2126/ // Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha
// 0.7152/0.0722 weighting). Alpha is ignored — contrast is a question about the opaque // is ignored — a translucent overlay's effective color is the caller's to compose first.
// hues; a translucent overlay's effective color is the caller's to compose first.
double relativeLuminance(const KitColor& c); double relativeLuminance(const KitColor& c);
// The WCAG contrast ratio between two colors, in [1, 21]. Symmetric; order-independent. // WCAG contrast ratio between two colors, in [1, 21]. Symmetric.
double contrastRatio(const KitColor& a, const KitColor& b); double contrastRatio(const KitColor& a, const KitColor& b);
// The contrast floor a text class must clear: 4.5 for Body, 3.0 for Large. The test that // Contrast floor a text class must clear: 4.5 for Body, 3.0 for Large. test_theme.cpp asserts
// proves the palette asserts contrastRatio(text, surface) >= textFloor(class) for every // contrastRatio(text, surface) >= textFloor(class) for every pair the kit actually draws.
// pair the kit actually draws.
double textFloor(TextClass cls); double textFloor(TextClass cls);
} // namespace reasampler::ui } // namespace reasampler::ui
+1 -1
View File
@@ -1,4 +1,4 @@
// tooltip — pure implementation. See tooltip.h. NO REAPER / SWELL / LICE / vendor. // tooltip — pure implementation. See tooltip.h.
#include "core/ui/tooltip.h" #include "core/ui/tooltip.h"
+14 -29
View File
@@ -1,22 +1,14 @@
#pragma once #pragma once
// tooltip — the REAPER-free layout math + text helper behind the bank_panel's custom hover-delay // tooltip — layout math + text helper behind the bank_panel's custom hover-delay tooltip. Button
// tooltip (Phase L, L5, refinement 2). Button FACES stay short (the terse shortLabel); hovering a // faces stay short; hovering pops a small tooltip with the full action name, "ReaSampler:"
// button for a short delay pops a small tooltip carrying the FULL action name with the // display prefix stripped. Custom LICE-kit draw, not the native Win32/SWELL tooltip control, for
// "ReaSampler:" display prefix stripped. The tooltip is a custom LICE-kit draw (NOT the native // cross-platform uniformity with the rest of the kit.
// Win32 / SWELL tooltip control) — chosen so it is uniform across platforms and consistent with
// the L1 kit (brief §tooltip mechanism). The DAW-bound parts (the hover timer, the LICE overlay
// draw, the kbd/action-name query) live in the shell; what is NOT DAW-bound — WHERE the tooltip
// box sits relative to its anchor button within the panel client, and stripping the display
// prefix — lives here, unit-tested outside the DAW. Mirror of prune_button / component_geometry.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include <string> #include <string>
namespace reasampler::ui { namespace reasampler::ui {
// The tooltip's box (top-left origin, SWELL/LICE convention). A zero-area rect means "do not // Zero-area means "do not draw" (degenerate inputs); caller checks empty() first.
// draw" (degenerate inputs); the caller checks empty() before drawing.
struct TooltipBox { struct TooltipBox {
int x = 0; int x = 0;
int y = 0; int y = 0;
@@ -30,10 +22,8 @@ struct TooltipBox {
} }
}; };
// Placement inputs, in pixels. // gap: vertical gap between anchor button and tooltip. padX/padY: text padding inside the box.
// * gap — vertical gap between the anchor button and the tooltip box. // margin: minimum clearance from client edges when clamping.
// * padX/padY — horizontal / vertical text padding inside the box.
// * margin — minimum clearance kept from the client edges when clamping.
struct TooltipSpec { struct TooltipSpec {
int gap = 4; int gap = 4;
int padX = 6; int padX = 6;
@@ -41,20 +31,15 @@ struct TooltipSpec {
int margin = 2; int margin = 2;
}; };
// Strips the action DISPLAY PREFIX from a full action name for the tooltip face. The registered // Strips the action display prefix (e.g. "ReaSampler: ") from a full action name for the
// gaccel name is composed as `prefix + phrase` (prefix from actionDisplayPrefix(), e.g. // tooltip face. If fullName doesn't start with prefix, returned unchanged (defensive). Empty
// "ReaSampler: "); the tooltip shows only the phrase. If `fullName` does not start with // prefix returns fullName unchanged.
// `prefix`, it is returned unchanged (defensive — a name from an unexpected source still shows).
// An empty prefix returns fullName unchanged.
std::string stripActionPrefix(const std::string& fullName, const std::string& prefix); std::string stripActionPrefix(const std::string& fullName, const std::string& prefix);
// Places a tooltip of pixel size (textW + 2*padX) x (textH + 2*padY) for the button rect // Places a tooltip of size (textW + 2*padX) x (textH + 2*padY) for the anchor button rect,
// (anchorX, anchorY, anchorW, anchorH), clamped inside the client rect (0,0,clientW,clientH). // clamped inside the client rect. Prefers BELOW the anchor, centered; flips ABOVE if it would
// Preference: BELOW the anchor, horizontally centred on it. If it would clip the bottom edge, // clip the bottom edge, then clamps to stay within `margin` of the client edges. Empty when the
// it flips ABOVE the anchor. It is then clamped horizontally (and vertically as a last resort) // text extent or client is degenerate. textW/textH are measured by the shell before calling.
// to stay within `margin` of the client edges. Returns an empty box when the text extent or the
// client is degenerate. `textW`/`textH` are the measured text extents (the shell measures with
// the kit font before calling).
TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH,
int textW, int textH, int clientW, int clientH, int textW, int textH, int clientW, int clientH,
const TooltipSpec& spec); const TooltipSpec& spec);