Cut core/ui and core/audio comment bloat ~60% (comments only, zero code change)
This commit is contained in:
+13
-28
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 ~8–12
|
|
||||||
// tabs). What is NOT DAW-bound — how N fixed-width tabs tile a strip of a given
|
|
||||||
// pixel width, where the overflow chevrons sit, which tab/chevron a click lands in,
|
|
||||||
// and how far the strip may scroll — lives here so it is unit-tested outside the
|
|
||||||
// DAW (CLAUDE.md §load-bearing split). The panel shell (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
@@ -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
@@ -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,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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user