From 3d3415f943cbd6b1a483e19a4bff20dccd4d679a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 20:49:02 -0400 Subject: [PATCH] Cut core/ui and core/audio comment bloat ~60% (comments only, zero code change) --- src/core/audio/peaks.cpp | 41 +++----- src/core/audio/peaks.h | 118 ++++++++------------- src/core/ui/action_bar.cpp | 38 +++---- src/core/ui/action_bar.h | 122 +++++----------------- src/core/ui/bank_grid.cpp | 55 +++------- src/core/ui/bank_grid.h | 158 +++++++++-------------------- src/core/ui/card_drag.cpp | 17 +--- src/core/ui/card_drag.h | 143 +++++++++----------------- src/core/ui/card_meta.cpp | 27 ++--- src/core/ui/card_meta.h | 46 ++------- src/core/ui/component_geometry.cpp | 16 +-- src/core/ui/component_geometry.h | 118 +++++++-------------- src/core/ui/drag_out.cpp | 15 ++- src/core/ui/drag_out.h | 134 +++++++----------------- src/core/ui/footer_bar.cpp | 8 +- src/core/ui/footer_bar.h | 102 ++++++------------- src/core/ui/mode_enable.cpp | 8 +- src/core/ui/mode_enable.h | 33 ++---- src/core/ui/overflow_menu.cpp | 7 +- src/core/ui/overflow_menu.h | 66 ++++-------- src/core/ui/prune_button.cpp | 5 +- src/core/ui/prune_button.h | 84 ++++----------- src/core/ui/rect.h | 32 ++---- src/core/ui/tab_strip.cpp | 25 ++--- src/core/ui/tab_strip.h | 106 ++++++------------- src/core/ui/theme.cpp | 92 +++++++---------- src/core/ui/theme.h | 91 ++++++----------- src/core/ui/tooltip.cpp | 2 +- src/core/ui/tooltip.h | 43 +++----- 29 files changed, 527 insertions(+), 1225 deletions(-) diff --git a/src/core/audio/peaks.cpp b/src/core/audio/peaks.cpp index 1d79625..034bec3 100644 --- a/src/core/audio/peaks.cpp +++ b/src/core/audio/peaks.cpp @@ -5,14 +5,11 @@ #include #include -// peaks implementation. +// peaks — pure implementation. See peaks.h. // -// One linear pass per channel. The frame->bin partition is computed with integer -// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the -// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That -// 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. +// One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for +// any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount) +// — earlier bins absorb the remainder, no rounding drift, no dropped tail. namespace reasampler::audio { @@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector& interleaved, std::size_t binCount) { Envelope envelope(channelCount); if (channelCount == 0) { - return envelope; // no channels -> no envelopes + return envelope; } - // Never read past what the buffer actually holds, even if the caller's - // frameCount overstates the buffer (defensive: no OOB on a short buffer). + // Never read past what the buffer actually holds, even if frameCount overstates it. const std::size_t availableFrames = interleaved.size() / channelCount; const std::size_t frames = std::min(frameCount, availableFrames); @@ -35,14 +31,10 @@ Envelope computeEnvelope(const std::vector& interleaved, bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0} for (std::size_t b = 0; b < binCount; ++b) { - // Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount). - // Guard against size_t overflow in b*frames and (b+1)*frames: binCount is - // 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. + // Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and + // unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB. 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 end = ((b + 1) * frames) / binCount; @@ -69,21 +61,17 @@ MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) { const int nbins = static_cast(bins.size()); if (columnCount <= 0 || nbins == 0) return MinMax{}; - // Clamp col to [0, columnCount-1]. if (col < 0) col = 0; if (col >= columnCount) col = columnCount - 1; - // Half-open bin range for this column, mirroring computeEnvelope's exact partition. - // 64-bit products: col*nbins can exceed int range for a large oversampled envelope - // (same overflow discipline as computeEnvelope's frame-span arithmetic above). + // Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit + // products: col*nbins can exceed int range for a large oversampled envelope. const std::int64_t begin64 = (static_cast(col) * nbins) / columnCount; const std::int64_t end64 = (static_cast(col) + 1) * nbins / columnCount; - // col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins. const int colBinBegin = static_cast(begin64); - // When the column spans no full bin (more columns than bins), use the enclosing bin - // so no column is left empty. + // When the column spans no full bin (more columns than bins), use the enclosing bin. const int scanEnd = (end64 > begin64) ? static_cast(end64) : colBinBegin + 1; const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins; @@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector& interleaved, AudioSample linearThreshold) { 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 frames = std::min(frameCount, availableFrames); if (frames == 0) return kNoFrameAboveThreshold; - // Scan backward: the first frame (from the end) whose loudest channel exceeds the - // threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps. + // Scan backward; `f` runs frames..1 so `f-1` never wraps. for (std::size_t f = frames; f > 0; --f) { const std::size_t frame = f - 1; const std::size_t base = frame * channelCount; diff --git a/src/core/audio/peaks.h b/src/core/audio/peaks.h index 6f3d5b6..7cae63e 100644 --- a/src/core/audio/peaks.h +++ b/src/core/audio/peaks.h @@ -1,32 +1,20 @@ #pragma once -// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved -// PCM. We compute our own thumbnails from the captured file rather than depending -// on REAPER's peak API: we own the file format, so this is simpler, testable, and -// 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. +// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute +// our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this +// is simpler, testable, and dependency-free. #include #include namespace reasampler::audio { -// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer -// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so -// peaks consumes that directly with no lossy conversion. If a capture ever lands -// 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. +// REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no +// lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's +// metadata struct of the same short name. using AudioSample = float; -// One bin of a channel's envelope: the extremes of every sample that fell in it. -// min <= max always. For an empty bin (more bins than frames), both are 0. +// One bin's extremes across the samples that fell in it. min <= max always; an empty bin +// (more bins than frames) is {0, 0}. struct MinMax { AudioSample min = 0.0f; AudioSample max = 0.0f; @@ -37,83 +25,61 @@ struct MinMax { // One channel's envelope: exactly `binCount` bins, in time order. using ChannelEnvelope = std::vector; -// Per-channel envelopes: outer index is channel (channelCount entries, order -// preserved — never mixed or folded), inner is that channel's bins. +// Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never +// mixed or folded), inner is that channel's bins. using Envelope = std::vector; // Computes a per-channel min/max envelope from interleaved PCM. // -// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. -// Size must be >= frameCount * channelCount; extra is ignored. -// channelCount channels per frame (the stride). Each channel is enveloped -// INDEPENDENTLY — no averaging, no stereo fold (precision -// invariant: channel count preserved). +// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be +// >= frameCount * channelCount; extra is ignored. +// channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no +// averaging, no stereo fold (channel count is preserved end to end). // frameCount frames (samples-per-channel) to consider. // binCount requested bins per channel. Honored exactly for any frameCount. // -// Frame->bin partition: frames are split into `binCount` contiguous spans as -// evenly as possible; when frameCount does not divide evenly, the remainder is -// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the -// tail is never dropped and no bin reads out of bounds. When binCount > frameCount -// the trailing empty bins are {0, 0}. +// Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible; +// when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the +// earliest bins, so the tail is never dropped and no bin reads out of bounds. // -// Defined behavior for degenerate input (no UB, no throw): -// binCount == 0 -> per channel: an empty bin vector. -// channelCount == 0 -> an empty envelope (no channels). -// frameCount == 0 -> per channel: binCount bins, all {0, 0}. +// Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel; +// channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}. Envelope computeEnvelope(const std::vector& interleaved, std::size_t channelCount, std::size_t frameCount, std::size_t binCount); -// The merged min/max for display column `col` (0-based, of `columnCount` total columns) -// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects -// to that column. This is the display-side collapse of an envelope computed at HIGHER -// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a -// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then -// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots. +// Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed +// ChannelEnvelope — the true extremes of every bin projecting to that column. This is the +// display-side collapse when the envelope was computed at a higher resolution than the drawn +// width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9}) +// renders as one gap-free span instead of two separated dots. // -// Bin->column mapping mirrors computeEnvelope's half-open partition: -// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount). -// When that range is empty (more columns than bins), the enclosing bin -// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is -// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to -// [0, columnCount-1]. Pure. +// Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins +// [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns +// than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty() +// returns {0, 0}; col is clamped to [0, columnCount-1]. MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col); -// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range -// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous: -// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for -// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly. +// Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous +// since no real frame index can reach it. inline constexpr std::size_t kNoFrameAboveThreshold = static_cast(-1); -// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max -// absolute value across all channels of that frame — NO stereo fold, just the -// loudest channel that frame) exceeds `linearThreshold`, returning that frame index. -// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input). +// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across +// all channels of that frame — no stereo fold) exceeds `linearThreshold`. Returns +// kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input). // -// This is the boundary primitive behind the realtime tail's decay-scan trim -// (docs/product/capture-tail.md §The realtime path): the recorded tail window is -// scanned back from the end for the last frame still above -72 dB, and the file is -// truncated one frame past it. Deliberately a separate primitive from -// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), -// this answers "the last frame above a level" (a boundary). Bending the bin-oriented -// envelope to a frame-exact boundary question is a worse fit (spec §option a). +// This is the boundary primitive behind the realtime tail's decay-scan trim (see +// docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last +// frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from +// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers +// "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact +// question is a worse fit. // -// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. -// Must hold >= frameCount * channelCount; extra is ignored, and a -// short buffer is clamped to what it actually holds (no OOB read). -// 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. +// linearThreshold 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 greater than this. std::size_t lastFrameAboveThreshold(const std::vector& interleaved, std::size_t channelCount, std::size_t frameCount, diff --git a/src/core/ui/action_bar.cpp b/src/core/ui/action_bar.cpp index 36da30b..9f9c17b 100644 --- a/src/core/ui/action_bar.cpp +++ b/src/core/ui/action_bar.cpp @@ -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" @@ -8,7 +8,6 @@ namespace reasampler::ui { namespace { -// The total button count across all clusters (empty clusters contribute nothing). int totalButtons(const std::vector& clusters) { int n = 0; for (const ClusterSpec& c : clusters) @@ -16,21 +15,16 @@ int totalButtons(const std::vector& clusters) { 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*/) { - const int hpad = 4; // horizontal text inset inside the button + const int hpad = 4; const int innerX = s.x + 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; } -// Tiles the first `visible` buttons into slots, cluster by cluster, left to right. This is the -// ONE placement routine; both computeBarSlots and hitTestActionBar drive it so draw and -// hit-test can never drift. `visible` is assumed already clamped to [0, total]. Returns the -// slots in ascending flat-index order. +// The one placement routine; computeBarSlots and hitTestActionBar both drive it so draw and +// hit-test can't drift apart. std::vector tile(const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec, int visible) { @@ -43,21 +37,20 @@ std::vector tile(const ActionBarRect& bar, if (btnH <= 0) return slots; int cursorX = bar.x + spec.sidePad; - int flatIndex = 0; // running flat action index across all clusters - int placed = 0; // buttons placed so far (stops at `visible`) + int flatIndex = 0; + int placed = 0; bool firstClusterEmitted = false; 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; - // Gap BEFORE this cluster (except the first non-empty one). if (firstClusterEmitted) cursorX += spec.clusterGap; firstClusterEmitted = true; for (int i = 0; i < c.count; ++i, ++flatIndex) { - if (placed >= visible) return slots; // overflow cut — stop cleanly - if (i > 0) cursorX += spec.buttonGap; // gap between buttons in the cluster + if (placed >= visible) return slots; + if (i > 0) cursorX += spec.buttonGap; ActionBarSlot s; s.index = flatIndex; @@ -76,9 +69,7 @@ std::vector tile(const ActionBarRect& bar, return slots; } -// The rightmost pixel the first `visible` buttons would occupy (bar.x + sidePad based). Used by -// 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. +// Mirrors tile()'s advance math so fit and layout agree. int rightEdgeFor(const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec, int visible) { if (visible <= 0) return bar.x + spec.sidePad; @@ -93,7 +84,7 @@ int rightEdgeFor(const ActionBarRect& bar, const std::vector& clust for (int i = 0; i < c.count; ++i) { if (placed >= visible) return cursorX; if (i > 0) cursorX += spec.buttonGap; - cursorX += spec.buttonWidth; // this button's right edge + cursorX += spec.buttonWidth; ++placed; if (placed >= visible) return cursorX; } @@ -113,8 +104,6 @@ BarFit computeBarFit(const ActionBarRect& bar, const std::vector& c } 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; for (int cand = 1; cand <= total; ++cand) { if (rightEdgeFor(bar, clusters, spec, cand) <= usableRight) @@ -138,7 +127,6 @@ std::vector computeBarSlots(const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec) { 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 || py < bar.y || py >= bar.y + bar.height) 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) return s.index; } - return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss + return -1; } } // namespace reasampler::ui diff --git a/src/core/ui/action_bar.h b/src/core/ui/action_bar.h index bb754c2..bfcf712 100644 --- a/src/core/ui/action_bar.h +++ b/src/core/ui/action_bar.h @@ -1,73 +1,29 @@ #pragma once #include "core/ui/rect.h" -// action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's -// TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a -// thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact -// bar of clusters, each button carrying a label sub-rect spanning -// 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. +// action_bar — layout + hit-test for the bank_panel's task-grouped toolbars: buttons cluster by +// task (Capture/Placement/Maintenance/Tagging/Switching); on a narrow panel, whole trailing +// buttons drop rather than shrink or clip. The destructive Prune button lives separately in +// prune_button, kept out of this cluster on purpose. #include namespace reasampler::ui { -// The task cluster a button belongs to (the L2 "group by task" mandate). The order here is -// NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum -// 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. +// Task cluster a button belongs to. Cluster order is caller-supplied via ClusterSpec, not fixed +// here; a slot just carries which cluster it landed in. enum class ActionCluster { - Capture, // capture item / track / realtime / batch — top toolbar, primary gesture - Placement, // insert at cursor / insert-conform — top toolbar, placing a sample - Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions - Tagging, // tag / untag selected tracks for the active mode — bottom toolbar (L4) - Switching, // activate Arrange / Design, toggle mode, show-both — bottom toolbar (L4) + Capture, + Placement, + Maintenance, + Tagging, + Switching, }; -// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the -// 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 +using ActionBarRect = Rect; -// One visible button's placement within the bar, top-left origin. `index` is the button's -// position in the caller's flat action list (the caller supplies actions in cluster order, so -// index also selects the action to fire on a hit). `cluster` is the task group it was laid out -// 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. +// One visible button's placement, top-left origin. `index` is its position in the caller's flat +// action list (cluster order), so index also selects the action to fire on a hit. Only buttons +// that fit get a slot — overflow is dropped whole, never clipped. struct ActionBarSlot { int index = 0; ActionCluster cluster = ActionCluster::Capture; @@ -75,9 +31,7 @@ struct ActionBarSlot { int y = 0; int width = 0; int height = 0; - // Label rect (absolute, top-left origin), inside `box`. The label spans the full button - // height — a single-row short label only (L6: keybinding sub-row removed from the face; - // binding is surfaced in the hover tooltip instead). + // Label sub-rect, full button height, horizontally inset so text clears the edge. int labelX = 0, labelY = 0, labelW = 0, labelH = 0; 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 -// in the left-to-right order it wants them drawn (top toolbar: Capture then Placement; bottom -// 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. +// One cluster's button count, in the order the caller wants it drawn. count == 0 skips the +// cluster (no gap emitted). Flat action indices run cluster-by-cluster in this order. struct ClusterSpec { ActionCluster cluster = ActionCluster::Capture; int count = 0; }; -// Layout inputs for the bar, in pixels. Defaults are the bank_panel action-bar metrics; the -// 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). +// Layout inputs, in pixels; defaults are the bank_panel action-bar metrics. struct ActionBarSpec { int buttonWidth = 108; int buttonGap = 4; @@ -116,35 +58,23 @@ struct ActionBarSpec { int verticalInset = 3; }; -// How many buttons (from the front, cluster by cluster) fit the bar at `spec.buttonWidth`. -// Split from slot tiling so the shell can size an overflow affordance / count without -// 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]. +// How many buttons (from the front) fit at spec.buttonWidth. Split out so the shell can size an +// overflow affordance without re-deriving it. A bar too narrow for even one button yields 0. struct BarFit { - int visibleCount = 0; // buttons that fit (laid out), counted from the front - int hiddenCount = 0; // total - visibleCount (the overflow, dropped whole) + int visibleCount = 0; + int hiddenCount = 0; }; BarFit computeBarFit(const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); -// Lays out the VISIBLE buttons (per computeBarFit) left-to-right in cluster order: buttons -// 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. +// Lays out the visible buttons (per computeBarFit) left-to-right in cluster order. std::vector computeBarSlots(const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); -// The flat action index the point (px, py) (SWELL/LICE top-left client coords) lands on, or -1 -// for a miss: outside the bar band, in an inter-button / inter-cluster gap, or past the last -// 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). +// Flat action index under (px, py), or -1 for a miss (outside the bar, in a gap, or past the +// last visible button). Gaps are real dead-zones here, not resolved to the nearest button. int hitTestActionBar(int px, int py, const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); diff --git a/src/core/ui/bank_grid.cpp b/src/core/ui/bank_grid.cpp index 2522527..85c35e8 100644 --- a/src/core/ui/bank_grid.cpp +++ b/src/core/ui/bank_grid.cpp @@ -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" @@ -9,8 +9,7 @@ namespace reasampler::ui { namespace { -// Builds a sorted, unique ascending index vector for the inclusive range [a, b] -// (order-agnostic in a/b). Both ends assumed already in-range by the caller. +// Sorted, unique ascending index vector for the inclusive range [a, b] (order-agnostic in a/b). std::vector rangeIndices(int a, int b) { if (a > b) std::swap(a, b); std::vector out; @@ -19,8 +18,6 @@ std::vector rangeIndices(int a, int b) { 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 s; s.indices = {index}; @@ -32,11 +29,9 @@ Selection singleSelection(int index) { } // namespace int columnsForWidth(int panelWidth, const GridSpec& spec) { - // Layout: [gap][cell][gap][cell]...[cell][gap]. n cells occupy - // 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. + // Layout: [gap][cell][gap][cell]...[cell][gap]; n cells occupy gap + n*(cellWidth+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; if (usable < spec.cellWidth) return 1; const int cols = usable / cell; @@ -68,15 +63,12 @@ std::vector computeCellRects(int itemCount, int contentHeight(int itemCount, int panelWidth, const GridSpec& spec) { if (itemCount <= 0) return 0; const int cols = columnsForWidth(panelWidth, spec); - // Ceil-divide item count by columns to get the row count (partial last row - // still occupies a full row of height). - const int rows = (itemCount + cols - 1) / cols; + const int rows = (itemCount + cols - 1) / cols; // ceil-divide return spec.gap + rows * (spec.cellHeight + spec.gap); } std::string thumbnailKeyString(const ThumbnailKey& key) { - // Length-prefix the sampleId so a delimiter byte inside an id cannot forge a - // collision with a different (id, width, generation) triple. + // Length-prefix sampleId so a delimiter byte inside it can't forge a collision. std::string s; s.reserve(key.sampleId.size() + 32); 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& rects) { for (std::size_t i = 0; i < rects.size(); ++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 && py >= r.y && py < r.y + r.height) return static_cast(i); @@ -110,15 +101,14 @@ Selection applyClick(const Selection& current, int index, bool ctrl, bool shift, int itemCount) { if (itemCount <= 0 || index < 0 || index >= itemCount) return current; - // Shift takes precedence over ctrl (documented): range-select from the anchor. if (shift) { const int anchor = current.anchor >= 0 && current.anchor < itemCount ? current.anchor - : index; // no valid anchor -> seed at the click + : index; Selection s; s.indices = rangeIndices(anchor, index); s.focus = index; - s.anchor = anchor; // anchor unchanged across a shift-range + s.anchor = anchor; return s; } @@ -126,15 +116,14 @@ Selection applyClick(const Selection& current, int index, bool ctrl, bool shift, Selection s = current; auto it = std::lower_bound(s.indices.begin(), s.indices.end(), index); if (it != s.indices.end() && *it == index) - s.indices.erase(it); // toggle OUT + s.indices.erase(it); else - s.indices.insert(it, index); // toggle IN (keeps sorted order) + s.indices.insert(it, index); s.focus = index; - s.anchor = index; // ctrl-click reseeds the range origin + s.anchor = index; return s; } - // Plain click: sole selection. return singleSelection(index); } @@ -143,8 +132,7 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount if (itemCount <= 0) return current; if (cols < 1) cols = 1; - // A fresh panel (no focus): the first key press focuses cell 0 without moving, - // so the user sees the caret appear before it steps. + // Fresh panel: first key press focuses cell 0 without moving. if (current.focus < 0 || current.focus >= itemCount) { if (shift) { Selection s; @@ -160,22 +148,15 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount int to = from; switch (key) { case NavKey::Left: - // Move one; clamp at cell 0 (stay put on the first cell). if (from > 0) to = from - 1; break; case NavKey::Right: - // Move one; clamp at the last cell (stay put on the last cell). if (from < itemCount - 1) to = from + 1; break; case NavKey::Up: - // Move up a row; if that leaves the grid (top row) stay put. if (from - cols >= 0) to = from - cols; break; 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; if (below < itemCount) to = below; @@ -189,7 +170,6 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount 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 ? current.anchor : from; @@ -203,23 +183,14 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount float compressAmplitudeForDisplay(float linear) { const float mag = linear < 0.0f ? -linear : linear; - // The linear magnitude at the floor threshold: 10^(kDisplayFloorDb/20). - // 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. + // std::pow isn't constexpr pre-C++20; derive at runtime, cheap since it's once per bin. const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f); 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); - - // Normalize to [0, 1]: 0 at kDisplayFloorDb, 1 at 0 dB. 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); return linear < 0.0f ? -clamped : clamped; } diff --git a/src/core/ui/bank_grid.h b/src/core/ui/bank_grid.h index c6845a6..f4bfe04 100644 --- a/src/core/ui/bank_grid.h +++ b/src/core/ui/bank_grid.h @@ -1,14 +1,7 @@ #pragma once #include "core/ui/rect.h" -// bank_grid — the REAPER-free layout math and cache-key logic behind the docked -// bank_panel (M5, Wave A). The panel shell (shell/panel/) owns the SWELL window, -// 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. +// bank_grid — layout math, hit-test, selection, and keyboard nav for the docked bank_panel grid, +// plus its thumbnail cache-key. The panel shell owns SWELL/LICE/PCM; this is the DAW-free half. #include #include @@ -17,50 +10,34 @@ namespace reasampler::ui { -// A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE -// convention). (x, y) is the top-left corner; width/height are the cell extents. -// 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 +// One cell's pixel rect, top-left origin. Draw bounds for one sample's thumbnail. +using CellRect = Rect; -// Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the -// TARGET cell size; the layout fits as many whole columns as the panel width -// 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. +// cellWidth/cellHeight are the target cell size; layout fits as many whole columns as the panel +// width allows (>= 1) and wraps rows as needed. gap is the spacing between cells and the margin. struct GridSpec { int cellWidth = 120; int cellHeight = 72; int gap = 8; }; -// Computes the number of columns that fit in a panel of the given pixel width for -// the spec. Always >= 1 (a panel narrower than one cell still shows one column, -// clipped by the window). Pure arithmetic — the panel passes its live client -// width here and to computeCellRects. +// Columns that fit a panel of the given width. Always >= 1 (a too-narrow panel still shows one +// clipped column). int columnsForWidth(int panelWidth, const GridSpec& spec); -// Tiles `itemCount` cells left-to-right, top-to-bottom into a panel of the given -// pixel width, honoring the spec's cell size and gap. Returns exactly itemCount -// 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). +// Tiles itemCount cells left-to-right, top-to-bottom. Returns exactly itemCount rects in item +// order. A partial last row is left-aligned, not centered or stretched. itemCount == 0 -> empty. std::vector computeCellRects(int itemCount, int panelWidth, const GridSpec& spec); -// The total pixel height the grid occupies for itemCount cells at the given panel -// width and spec (top margin + rows*cellHeight + inter-row gaps + bottom margin). -// 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). +// Total pixel height the grid occupies (top margin + rows*cellHeight + inter-row gaps + bottom +// margin); 0 when itemCount == 0. int contentHeight(int itemCount, int panelWidth, const GridSpec& spec); -// Identifies one cached thumbnail. A cached envelope is valid only while the -// sample's identity, the draw width it was computed at, and the bank generation -// it was computed under all match. Width is part of the key because the envelope -// 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. +// Identifies one cached thumbnail. Valid only while sample identity, the draw width it was +// computed at (the envelope has exactly `width` bins per channel), and bank generation all match; +// generation bump invalidates every cached entry without diffing. struct ThumbnailKey { std::string sampleId; int width = 0; @@ -72,35 +49,22 @@ struct ThumbnailKey { } }; -// A stable string form of the key, suitable as a map key. Deterministic: the same -// key always yields the same string, distinct keys always differ (the sampleId is -// length-prefixed so an id containing the delimiter cannot collide with another). +// Stable string form of the key for use as a map key. sampleId is length-prefixed so a delimiter +// byte inside an id can't forge a collision. std::string thumbnailKeyString(const ThumbnailKey& key); -// --- Interaction (M5 Wave B): 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. +// --- Interaction: hit-test, selection, keyboard nav -------------------------- -// Hit-tests a point (SWELL/LICE top-left client coords) against a cell-rect list. -// Returns the index of the FIRST rect that contains the point, or -1 for a miss -// (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. +// Index of the first rect containing (px, py), or -1 for a miss (gap, margin, below last row). +// Half-open bounds so adjacent rects never both claim a pixel. int hitTestCell(int px, int py, const std::vector& rects); -// The panel's selection state. `indices` is the selected set as a SORTED, unique -// ascending vector (deterministic for tests and for highlight iteration). `focus` -// is the cell the caret sits on — the audition/extend target — or -1 when nothing -// 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. +// Panel selection state. `indices` is sorted unique ascending (deterministic for tests and +// highlight order). `focus` is the caret cell (audition/extend target), -1 when none. `anchor` is +// the fixed end a shift-range extends from, -1 when none. Empty selection: focus == anchor == -1. // -// Invariants (upheld by the pure mutators below, asserted in tests): -// * indices is sorted ascending with no duplicates; -// * every index (and focus/anchor when >= 0) is in [0, itemCount); -// * focus, when >= 0, is a member of indices. +// Invariants upheld by the mutators below: indices sorted/unique; every index (and focus/anchor +// when >= 0) is in [0, itemCount); focus, when >= 0, is a member of indices. struct Selection { std::vector indices; int focus = -1; @@ -113,66 +77,42 @@ struct Selection { bool empty() const { return indices.empty(); } }; -// Applies a mouse click on cell `index` to `current`, returning the new selection. -// Modifier semantics (standard multi-select, matching file-manager conventions): -// * plain (no modifier): select ONLY `index`; focus = anchor = index. -// * ctrl: TOGGLE `index` in/out of the set; focus = index. Anchor moves to -// index on add, and to index on remove too (a ctrl-click reseeds the -// range origin at the clicked cell). If the toggle empties the set, -// 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. +// Applies a click on cell `index` to `current`. Modifier semantics (file-manager convention): +// * plain: select only `index`; focus = anchor = index. +// * ctrl: toggle `index` in/out; focus = index; anchor reseeds to index either way. +// * shift: select the inclusive range [anchor, index]; focus = index, anchor unchanged. +// No prior anchor behaves like a plain click. +// ctrl+shift together: shift wins (range select). index out of range or itemCount <= 0: no-op. Selection applyClick(const Selection& current, int index, bool ctrl, bool shift, int itemCount); -// A directional key for keyboard navigation. REAPER-free (the shell maps VK_* to -// these) so nav math is testable without SWELL. Enter/Space/Esc are NOT here: they -// drive audition, which is a shell concern (no selection math), so the shell reads -// those key codes directly. +// Directional key for nav; Enter/Space/Esc drive audition and are a shell concern, not modelled +// here. enum class NavKey { Left, Right, Up, Down, Home, End }; -// Moves the focus by one step for `key` in a grid of `cols` columns holding -// `itemCount` cells, returning the new selection. `cols` >= 1. -// * Left/Right move by one cell in linear (row-major) order; Up/Down move by -// `cols`. Movement CLAMPS at the grid ends (no wrap): Right on the last cell, -// Left on the first, Up on the top row, Down past the last cell all stay put. -// (Clamp, not wrap: wrap on a partial last row is surprising and error-prone; -// clamp is the predictable choice — flagged as the deliberate decision.) -// * Down from the second-to-last row into a column with no cell in the last row -// clamps to the last cell rather than overshooting past itemCount. -// * 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. +// Moves focus by one step for `key` in a `cols`-column grid of `itemCount` cells. +// * Left/Right move linearly; Up/Down move by `cols`. Movement CLAMPS at the grid edges (no +// wrap) — deliberate: wrap on a partial last row is surprising. +// * Down from the row above a missing partial-last-row cell clamps to the last cell rather than +// overshooting past itemCount. +// * Without shift: moved-to cell becomes the sole selection (focus = anchor = newIndex). +// * With shift: focus moves to newIndex, selection becomes the inclusive range from anchor +// (seeded at the origin cell on first extend). +// * Empty selection: first arrow focuses cell 0 without moving. // itemCount <= 0 returns `current` unchanged. Selection navigate(const Selection& current, NavKey key, int cols, int itemCount, bool shift); // --- Waveform display compression -------------------------------------------- -// -// Maps a raw linear amplitude magnitude to a perceptual display fraction so -// quiet and medium content remains visible in the thumbnail. -// -// 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. +// Maps raw linear amplitude to a perceptual display fraction so quiet content stays visible. + +// Below this, amplitude is treated as silence (display fraction 0). Only knob for the curve. constexpr float kDisplayFloorDb = -60.0f; -// Maps a signed linear amplitude value in [-1, 1] (a raw envelope extreme such -// as PeakBin::max or PeakBin::min) to a signed display fraction in [-1, 1]. -// -// The magnitude |linear| is converted to dB, clamped to [kDisplayFloorDb, 0], -// 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. +// Maps a signed linear amplitude in [-1, 1] (a raw envelope extreme, e.g. PeakBin::max/min) to a +// 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 +// stays 0; full-scale (|linear| == 1.0f) returns exactly +-1.0f. float compressAmplitudeForDisplay(float linear); } // namespace reasampler::ui diff --git a/src/core/ui/card_drag.cpp b/src/core/ui/card_drag.cpp index 6f14fd9..e5712db 100644 --- a/src/core/ui/card_drag.cpp +++ b/src/core/ui/card_drag.cpp @@ -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" @@ -6,7 +6,6 @@ namespace reasampler::ui { 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) { return px >= c.x && px < c.x + c.width && 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, const DragState& state, const DragModifiers& mods) { - // No drag / empty payload: nothing to do. 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; - // Precedence 2: over a tab / the other bank -> move (or copy on Ctrl). if (mods.region == DropRegion::OtherBankOrTab) return mods.ctrl ? CardGesture::Copy : CardGesture::Move; - // Precedence 3: within the same bank's own grid -> reorder / replace. 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; return CardGesture::Reorder; } - // Dead space inside the client: a drop here is a no-op. return CardGesture::None; } @@ -56,7 +48,7 @@ std::vector computeSlotRects(int maxSlot, int panelWidth, if (maxSlot < 0) return rects; 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(count)); for (int slot = 0; slot < count; ++slot) { @@ -76,16 +68,13 @@ std::vector computeSlotRects(int maxSlot, int panelWidth, std::vector computeSlotRectsForDrop(int maxSlot, int panelWidth, const GridSpec& 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 newMax = firstTrailing + cols - 1; // fills one full trailing row + const int newMax = firstTrailing + cols - 1; // one full trailing row return computeSlotRects(newMax, panelWidth, spec); } int hitTestSlot(int px, int py, const std::vector& 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 && py >= r.y && py < r.y + r.height) return r.slot; diff --git a/src/core/ui/card_drag.h b/src/core/ui/card_drag.h index 1e2b379..731206c 100644 --- a/src/core/ui/card_drag.h +++ b/src/core/ui/card_drag.h @@ -1,32 +1,20 @@ #pragma once -// card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three -// pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing -// 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. +// card_drag — decision logic behind the in-grid reorder drag. Mirror of drag_out::decideGesture; +// SWELL wiring, SetCursor, cursor resources, and drop-target draw stay in the shell. // -// 1. GESTURE PRECEDENCE (F3 settled). A live drag resolves to exactly one gesture, in a -// strict precedence the shell evaluates on every mouse-move / at drop: -// (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) -// (3) else drop within the SAME bank's grid -> Reorder | Replace -// - empty slot -> Reorder (place there) -// - occupied slot, no modifier -> Reorder (insert-before-and-shift) -// - occupied slot, Alt held -> Replace (Alt-replace-over-occupied) -// So leave-client wins first, then other-bank, then same-bank-grid = reorder/replace. -// This keeps the reorder gesture from ever stealing a bank-move or OS-drag. +// Gesture precedence, evaluated on every mouse-move / at drop, strict order: +// (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) +// (3) else drop within the SAME bank's grid -> Reorder | Replace +// - empty slot -> Reorder (place there) +// - occupied slot, no modifier -> Reorder (insert-before-and-shift) +// - occupied slot, Alt held -> Replace +// Leave-client wins first, then other-bank, then same-bank-grid — so reorder can never steal a +// bank-move or an OS-drag. // -// 2. SLOT HIT-TEST. Which grid SLOT a pointer sits over, sparse-aware: the grid tiles -// slots 0..maxSlot including empty ones, so hit-testing maps a point to a slot index -// (empty or occupied) or -1 for a miss. The pixel<->slot rect math extends bank_grid's -// 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. +// Also owns: sparse-aware slot hit-test (a point -> grid slot, empty or occupied, extending +// bank_grid's dense tiling), and the gesture -> cursor-cue mapping (Replace's cue appears only +// when Alt is actually held over an occupied slot). #include @@ -35,79 +23,54 @@ namespace reasampler::ui { -// Which drop region the pointer currently sits over WITHIN the client rect. The shell -// classifies the live pointer against its own region geometry (tab strip / other bank -// 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.) +// Which drop region the pointer sits over within the client rect; the shell classifies against +// its own region geometry and passes the verdict — card_drag knows only precedence over these. enum class DropRegion { - SameBankGrid, // over the dragged samples' OWN bank grid — a reorder/replace target - OtherBankOrTab, // over a tab or the other region's bank — a move/copy target - DeadSpace, // inside the client but over no drop target (header, footer, gap) + SameBankGrid, // over the dragged samples' OWN bank grid — reorder/replace target + OtherBankOrTab, // over a tab or the other region's bank — move/copy target + 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 { - None, // no drag under way, or an empty payload — do nothing - OsDragOut, // pointer left the client rect — hand off to the native OS drag (drag_out) - Move, // drop over another bank/tab, no Ctrl — move the samples there - Copy, // drop over another bank/tab, Ctrl held — copy the samples there - Reorder, // drop within the same bank grid — reorder to the target slot - Replace, // drop within the same bank grid, Alt over an OCCUPIED slot — replace + None, + OsDragOut, + Move, + Copy, + Reorder, + Replace, }; -// The 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). +// Live drag inputs the precedence decision needs beyond position + client rect. struct DragModifiers { DropRegion region = DropRegion::DeadSpace; - int targetSlot = -1; - bool slotOccupied = false; - bool ctrl = false; - bool alt = false; + int targetSlot = -1; // slot under the pointer in SameBankGrid; -1 otherwise + bool slotOccupied = false; // drives Reorder vs Replace + bool ctrl = false; // Copy vs Move over another bank + 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 -// `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). +// Resolves the gesture for a drag at pointer (px, py) over `client`. See precedence above. CardGesture decideCardGesture(int px, int py, const PanelClientRect& client, 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 { - Default, // arrow — no drag, or dead space - Reorder, // within-bank reorder - Move, // move to another bank/tab - Copy, // copy to another bank/tab - OsDragOut, // pointer left the client (the OS drag loop owns the cursor once handed off) - Replace, // Alt-replace over an occupied slot + Default, + Reorder, + Move, + Copy, + OsDragOut, + Replace, }; -// Maps a resolved gesture to its cursor cue (pure — the shell owns SetCursor only). CursorCue cursorForGesture(CardGesture g); // --- Sparse-aware slot layout + hit-test -------------------------------------- -// The pixel rect of one grid SLOT (empty or occupied). Distinct from bank_grid's CellRect -// only in intent — a SlotCellRect carries the slot index it draws, so the shell can map a -// 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). +// One grid SLOT's pixel rect (empty or occupied); carries its slot index so the shell can map a +// rect back to the model slot without a parallel array. struct SlotCellRect { - int slot = 0; // the model slot this rect represents (0..maxSlot) + int slot = 0; int x = 0; int y = 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 -// grid spec — the sparse-aware sibling of bank_grid::computeCellRects. Every slot in -// [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. +// Tiles slots [0, maxSlot] inclusive (empty slots included, so a gap draws and a drop targets it +// precisely). maxSlot < 0 -> empty. Same column/row math as bank_grid::computeCellRects. std::vector computeSlotRects(int maxSlot, int panelWidth, const GridSpec& spec); -// Like computeSlotRects but extends one full trailing row of slots beyond maxSlot so a -// drop pointer past the last occupied card still resolves to a valid target slot. The -// trailing slots (maxSlot+1 .. maxSlot+cols) are empty — a drop on any of them calls -// 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). +// Like computeSlotRects but extends one full trailing row past maxSlot so a drop pointer beyond +// the last occupied card still resolves to a valid (empty) target slot. Drop hit-testing only — +// the draw path uses computeSlotRects, no ghost row in the visual. std::vector computeSlotRectsForDrop(int maxSlot, int panelWidth, const GridSpec& spec); -// Hit-tests a point against slot rects (half-open bounds, matching hitTestCell). Returns -// the SLOT index (rect.slot) of the first rect containing the point, or -1 on a miss (gap, -// margin, below the last row). NOTE the return is the slot index, NOT the vector index — -// callers reason in model slots. +// Slot index (rect.slot, NOT the vector index) of the first rect containing the point, or -1 on +// a miss. Half-open bounds, matching hitTestCell. int hitTestSlot(int px, int py, const std::vector& rects); } // namespace reasampler::ui diff --git a/src/core/ui/card_meta.cpp b/src/core/ui/card_meta.cpp index 8987dbf..12b0176 100644 --- a/src/core/ui/card_meta.cpp +++ b/src/core/ui/card_meta.cpp @@ -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" @@ -8,33 +8,26 @@ namespace reasampler::ui { 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 {}; 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) - // quarter-notes, so a beat lasts (60/tempo) * (4/denom) seconds. beats = len / that. + // A quarter-note is 60/tempo s; a beat is (4/denom) quarter-notes. const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom); double totalBeats = len / secondsPerBeat; - // Snap to an exact beat when we are within a hundredth-of-a-beat epsilon of one, so a - // bar-aligned capture reads "2.1.00" rather than "1.4.99" from FP error just under the - // boundary. The epsilon is well below the .01 display quantum, so it never mis-rounds a - // genuinely fractional length. + // Snap to an exact beat within epsilon so a bar-aligned capture reads "2.1.00" rather than + // "1.4.99" from FP error just under the boundary. const double snapped = std::floor(totalBeats + 0.5); 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 frac = totalBeats - wholeBeats; - // Bars/beats are 1-based; beat cycles 1..timeSigNum within a bar. const long wb = static_cast(wholeBeats); - const long bar = wb / m.timeSigNum + 1; // 1-based bar - const long beat = wb % m.timeSigNum + 1; // 1-based beat within the bar + const long bar = wb / m.timeSigNum + 1; + const long beat = wb % m.timeSigNum + 1; - // Subdivision: hundredths of a beat, floored (0..99). A decorative display quantum. int sub = static_cast(std::floor(frac * 100.0)); if (sub < 0) sub = 0; if (sub > 99) sub = 99; @@ -48,12 +41,10 @@ std::string formatSecondsMs(double lengthSeconds) { double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0; long secs = static_cast(std::floor(len)); - // Round to the nearest millisecond (not floor): FP error means 62.037 s stores as - // 62.0369999... and a raw floor would render "62.036". +0.5 before truncation rounds - // to the closest ms, which is what a wall-clock read-out should show. + // Round to nearest ms, not floor: FP storage error would otherwise render e.g. "62.036" + // for a value that should read "62.037". int ms = static_cast((len - static_cast(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; } + if (ms >= 1000) { ms -= 1000; ++secs; } // rounding can carry into the next second if (ms < 0) ms = 0; char buf[48]; diff --git a/src/core/ui/card_meta.h b/src/core/ui/card_meta.h index a561cd3..c314690 100644 --- a/src/core/ui/card_meta.h +++ b/src/core/ui/card_meta.h @@ -1,23 +1,14 @@ #pragma once -// card_meta — pure formatting for the L7 decorative card metadata overlay. Each bank -// card overlays capture length as bars.beats.subdivisions (bottom-LEFT, musical) and -// seconds.milliseconds (bottom-RIGHT, wall-clock). Both read-outs are DECORATIVE and -// 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. +// card_meta — formatting for the bank card's decorative metadata overlay: capture length as +// bars.beats.subdivisions (bottom-left, musical) and seconds.milliseconds (bottom-right, +// wall-clock). Both are non-interactive; bank_panel draws them via the kit. #include namespace reasampler::ui { -// The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp): -// lengthSeconds — captured length in wall-clock seconds (>= 0). -// 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. +// Musical length inputs, taken straight off a Sample's capture-time stamp. tempoBpm 0 = unknown; +// timeSigNum/Denom 0 = unstamped. struct MusicalLength { double lengthSeconds = 0.0; double tempoBpm = 0.0; @@ -25,31 +16,16 @@ struct MusicalLength { 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 -// (4 / timeSigDenom) quarter-notes; a bar holds timeSigNum beats. From lengthSeconds we -// get total beats, split into whole bars (÷ timeSigNum) + whole leftover beats + a -// subdivision remainder scaled to 1..N of the next beat. The output is 1-BASED and -// 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). +// 1-based, zero-padded to two subdivision digits: "1.1.00" is a bar-aligned/zero-length capture, +// "2.3.50" is 1 bar + 2 beats + half a beat. Unstamped meter or unknown tempo (tempoBpm <= 0) +// returns "" — no musical read-out is derivable, caller keeps the s.ms read-out. Subdivision is +// 0..99 (hundredths of a beat), floored — a display quantum, not tick-accurate PPQ. std::string formatBarsBeats(const MusicalLength& m); // 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). -// 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). +// "S.mmm", rounded to nearest ms. Negative length clamps to "0.000". std::string formatSecondsMs(double lengthSeconds); } // namespace reasampler::ui diff --git a/src/core/ui/component_geometry.cpp b/src/core/ui/component_geometry.cpp index 3e9d521..6157990 100644 --- a/src/core/ui/component_geometry.cpp +++ b/src/core/ui/component_geometry.cpp @@ -1,5 +1,4 @@ -// component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL / -// LICE / vendor. Standard library only. +// component_geometry — pure implementation. See 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.width = cell.width - 2 * padding; b.height = cell.height - 2 * padding; - if (b.empty()) return {}; // padding collapsed the cell -> suppress + if (b.empty()) return {}; return KitButtonBox{b}; } SliderGeometry computeSlider(const KitBox& control, double value, int handleSize, int trackThickness) { 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 (value < 0.0) value = 0.0; @@ -34,8 +32,6 @@ SliderGeometry computeSlider(const KitBox& control, double value, 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; track.x = control.x + half; 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.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(value * track.width + 0.5); KitBox handle; handle.x = centre - half; @@ -51,7 +46,6 @@ SliderGeometry computeSlider(const KitBox& control, double value, handle.width = handleSize; handle.height = handleSize; - // Filled portion: from the track's left up to the handle centre. KitBox filled; filled.x = track.x; 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) { if (list.empty() || rowHeight <= 0 || index < 0) return {}; const int top = list.y + index * rowHeight; - // Fully below the list bottom -> clipped away entirely -> no box. if (top >= list.y + list.height) return {}; KitBox b; b.x = list.x; b.y = top; b.width = list.width; - b.height = rowHeight; // a partially-visible last row keeps full height; caller clips + b.height = rowHeight; return ListRowBox{index, b}; } int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount) { if (list.empty() || rowHeight <= 0 || rowCount <= 0) return -1; - // Outside the list band entirely. if (px < list.x || px >= list.x + list.width || py < list.y || py >= list.y + list.height) return -1; 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; } diff --git a/src/core/ui/component_geometry.h b/src/core/ui/component_geometry.h index 7455c47..89876e6 100644 --- a/src/core/ui/component_geometry.h +++ b/src/core/ui/component_geometry.h @@ -1,100 +1,64 @@ #pragma once #include "core/ui/rect.h" -// component_geometry — the REAPER-free, LICE-free geometry + hit-test math for the shared -// drawing kit's generic components (Phase L, L1): a button box, a slider's track/handle, -// and a list row. These are the kit-level primitives that DON'T already have a pure owner: -// 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 +// component_geometry — geometry + hit-test math for the shared drawing kit's generic components: +// a button box, a slider's track/handle, and a list row. bank_grid / tab_strip / prune_button +// stay the source of truth for the surfaces they own; this carries only the reusable component // 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 { -// A generic pixel box, top-left origin (SWELL/LICE convention). Shared shape for the kit -// component rects below. A zero-area box (empty()) means "nothing to draw / hit" — the -// same graceful-suppression convention prune_button uses. -using KitBox = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// A generic pixel box, top-left origin. empty() means "nothing to draw / hit". +using KitBox = Rect; -// True iff (px, py) falls inside `box`, half-open bounds [x, x+width) x [y, y+height) — -// 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). +// Half-open bounds [x, x+width) x [y, y+height); an empty box claims no point. bool hitTestBox(int px, int py, const KitBox& box); // --- Button ------------------------------------------------------------------ -// -// A button drawn inside a host cell, inset by a uniform padding so it reads as a raised -// control rather than a full-bleed fill (the kit's drawButton draws the micro-gradient -// 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. + +// A button drawn inside a host cell, inset by uniform padding so it reads as raised rather than +// full-bleed. Distinct from prune_button, which owns its own placement within its strip. struct KitButtonBox { KitBox 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 -// empty box (suppressed) when the cell is degenerate or the padding would collapse it to -// zero-or-negative area — the caller then draws nothing (graceful, mirrors prune_button). -// padding < 0 is treated as 0. +// Button box inside `cell`, inset uniformly by `padding`. Returns an empty box (suppressed) when +// the cell is degenerate or padding would collapse it to zero-or-negative area. padding < 0 -> 0. KitButtonBox computeButtonBox(const KitBox& cell, int padding); // --- Slider (horizontal) ----------------------------------------------------- -// -// A horizontal slider: a track spanning the control width (inset at both ends by the -// handle's half-width so the handle never clips past the track), and a square handle -// 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. + +// track spans the control width, inset at both ends by half the handle width so the handle never +// clips past it. handle is centered on the track, positioned by the normalized value. struct SliderGeometry { - KitBox track; // the full track rect (the groove) - KitBox filled; // the filled portion from the track's left up to the handle center - KitBox handle; // the draggable handle rect + KitBox track; + KitBox filled; // filled portion from track's left up to the handle center + KitBox handle; bool operator==(const SliderGeometry& o) const { 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 -// square handle of side `handleSize`. The track is vertically centered at a fixed -// `trackThickness`, inset horizontally by handleSize/2 at each end so the handle's travel -// stays within `control`. value is clamped to [0, 1]; a value of 0 puts the handle flush -// 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. +// Lays out a horizontal slider inside `control` for normalized `value` in [0, 1] with a square +// handle of side `handleSize`, track vertically centered at `trackThickness`. value clamps to +// [0, 1]. Returns all-empty boxes when the control is too small to host the handle, or when +// handleSize/trackThickness <= 0. SliderGeometry computeSlider(const KitBox& control, double value, int handleSize, int trackThickness); -// The normalized value [0, 1] a click at px maps to, for a slider laid out in `control` -// with `handleSize` (the inverse of computeSlider's handle placement — a track jump). -// px left of / at the track start yields 0.0, at/right of the track end yields 1.0, -// 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. +// Inverse of computeSlider's handle placement (a track-jump click): normalized value [0, 1] a +// click at px maps to. Clamps to [0, 1] outside the track; 0.0 for a degenerate/too-small +// control. py unused (horizontal slider maps X only) — caller gates with hitTestBox(control) first. double sliderValueAt(int px, const KitBox& control, int handleSize); // --- List row ---------------------------------------------------------------- -// -// A single selectable row in a vertical list: full-width, fixed height, stacked from the -// list's top by index (no scroll — the caller offsets the list origin for scroll). The -// 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. + +// One selectable row: full-width, fixed height, stacked from the list's top by index (no scroll +// — caller offsets the list origin for that). struct ListRowBox { - int index = 0; // the row's index in the caller's list (0-based, top-first) + int index = 0; KitBox box; 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 -// stack from list.y; row i spans [list.y + i*rowHeight, +rowHeight). Returns an empty box -// when the list is degenerate, rowHeight <= 0, index < 0, or the row would fall entirely -// 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. +// Row box for `index` inside `list` at `rowHeight` per row; rows stack from list.y. Empty when +// the list is degenerate, rowHeight <= 0, index < 0, or the row falls entirely below the list's +// bottom. A partially-visible last row IS returned — caller clips the draw. 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 -// `rowHeight`. Returns -1 for a miss: outside the list bounds, in the list band but below -// 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. +// Index of the row a point lands on, or -1 for a miss (outside bounds, or in the empty tail past +// `rowCount` rows). rowCount bounds the hit so blank space past the last row is a clean miss. int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount); // --- Waveform column count --------------------------------------------------- -// -// The number of pixel columns drawWaveform renders inside `box` (its fixed 2px side -// insets), never negative. Callers pass this count directly as the `binCount` argument to -// peaks::computeEnvelope — one bin per column is the correct resolution, and -// 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. + +// Pixel columns drawWaveform renders inside `box` (its fixed 2px side insets), never negative. +// Pass directly as peaks::computeEnvelope's binCount — one bin per column is correct resolution; +// overbinning doesn't improve render quality and wastes memory/CPU. int waveformColumnCount(const KitBox& box); } // namespace reasampler::ui diff --git a/src/core/ui/drag_out.cpp b/src/core/ui/drag_out.cpp index 06a10e3..1a84c94 100644 --- a/src/core/ui/drag_out.cpp +++ b/src/core/ui/drag_out.cpp @@ -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" @@ -8,7 +8,6 @@ namespace reasampler::ui { 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) { return px >= c.x && px < c.x + c.width && py >= c.y && py < c.y + c.height; @@ -20,10 +19,8 @@ DragGesture decideGesture(int px, int py, const PanelClientRect& client, const DragState& state) { if (!state.dragging || !state.hasArmedSamples) return DragGesture::None; if (insideClient(px, py, client)) return DragGesture::Internal; - // Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is - // still over REAPER's own UI is an instrument drop (heading for a track's FX button); - // anything else (a multi-capture payload, or the pointer off REAPER entirely) is the - // unchanged M11 OS drag-out. + // Outside the client: a single-capture drag still over REAPER's own UI is an instrument + // drop; anything else (multi-capture, or pointer off REAPER entirely) is an OS drag-out. if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop; return DragGesture::OsDrag; } @@ -34,15 +31,15 @@ PathList assemblePathList(const std::vector& resolved) { seen.reserve(resolved.size()); for (const ResolvedSample& s : resolved) { - if (s.absolutePath.empty()) { // shell could not resolve it + if (s.absolutePath.empty()) { ++out.skippedUnresolved; continue; } - if (!s.fileExists) { // stale index entry, file gone + if (!s.fileExists) { ++out.skippedMissing; continue; } - if (!seen.insert(s.absolutePath).second) { // already emitted this path + if (!seen.insert(s.absolutePath).second) { ++out.skippedDuplicate; continue; } diff --git a/src/core/ui/drag_out.h b/src/core/ui/drag_out.h index aee7a2e..b859945 100644 --- a/src/core/ui/drag_out.h +++ b/src/core/ui/drag_out.h @@ -1,31 +1,17 @@ #pragma once #include "core/ui/rect.h" -// drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS -// drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are -// 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). +// drag_out — decision logic behind the bank_panel's native OS drag-out. OLE/SWELL initiation and +// the 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 -// already runs an INTERNAL drag: press a selected cell, cross a threshold, drop onto -// a pool/banks region or a tab to move/copy the samples between banks. That drag lives -// entirely INSIDE the panel client rect. The OS drag is a DISTINCT gesture with a -// distinct, discoverable boundary: while a drag is armed with samples in the payload, -// 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). +// Gesture boundary: the panel's own internal drag (press a selected cell, drop onto a pool/bank +// region or tab) lives entirely inside the panel client rect. The moment the pointer LEAVES that +// rect while a drag is armed with samples, the gesture becomes OS-bound — dragged out to another +// window/Explorer/DAW. A single-capture drag that leaves the rect but is still over REAPER's own +// UI is instead an InstrumentDrop (heading for a track's FX button); do not regress this boundary. // -// 2. PATH-LIST ASSEMBLY. The OS drop carries absolute file paths (Windows CF_HDROP / -// macOS file-list pasteboard). Turning the armed sample ids into that path list — -// resolving each id to its already-on-disk bank file, de-duping, and applying an -// 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. +// Path-list assembly: turns armed sample ids into the absolute path list the OS drop carries +// (Windows CF_HDROP / macOS file-list pasteboard) — set algebra only; the shell resolves each id +// to its on-disk bank file. No temp files; copy-only is enforced at the OS layer (drag_out_win). #include #include @@ -34,99 +20,53 @@ namespace reasampler::ui { // --- Gesture boundary --------------------------------------------------------- -// The panel's client rectangle in its own client coordinates (top-left origin, the SWELL/ -// LICE convention). width/height are the extents; a point (px, py) is INSIDE when -// x <= px < x + width and y <= py < y + height (half-open, matching the panel's other -// 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 panel's client rect, own client coords, top-left origin. Half-open: [x, x+width) x +// [y, y+height). +using PanelClientRect = Rect; -// The live drag state the shell tracks, reduced to what the boundary decision needs: -// whether a drag is currently active (threshold crossed) and whether the armed payload -// 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. +// Live drag state reduced to what the boundary decision needs. Pre-threshold "armed but not yet +// dragging" is not a drag for this decision. struct DragState { - bool dragging = false; // threshold crossed; a drag is in progress - bool hasArmedSamples = false; // the drag payload holds >= 1 sample id - bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop) - bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied) + bool dragging = false; // threshold crossed; a drag is in progress + bool hasArmedSamples = false; // payload holds >= 1 sample id + bool singleCapture = false; // payload holds EXACTLY one sample (arms InstrumentDrop) + 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. enum class DragGesture { - None, // no drag under way, or an empty payload — do nothing - Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag - InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI — - // the shell hover-tracks the TCP FX button and, on release, adds a - // ReaSampler 9000 instance preloaded with the dragged capture. - OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS + None, // no drag under way, or an empty payload + Internal, // dragging inside the panel — bank-to-bank move/copy + InstrumentDrop, // single-capture drag left the panel but is over REAPER's UI — shell + // hover-tracks the TCP FX button; on release adds a preloaded instance + OsDrag, // dragging with samples, pointer left REAPER entirely — hand to the OS }; -// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. -// * Not dragging (or no armed samples): None — the shell ignores the move. -// * 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. +// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. Position-only +// + state-only (no hidden state), so re-entry back inside always returns Internal. DragGesture decideGesture(int px, int py, const PanelClientRect& client, const DragState& state); // --- Path-list assembly ------------------------------------------------------- -// One armed sample reduced to what path assembly needs: the resolved ABSOLUTE file path -// the shell computed for it (empty when the shell could not resolve it — e.g. no project -// 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. +// One armed sample reduced to what path assembly needs: the shell-resolved absolute path (empty +// if unresolvable) and whether it exists on disk. struct ResolvedSample { - std::string absolutePath; // resolved absolute path, or "" when unresolvable - bool fileExists = false; // shell stat() result — drives the skip-missing policy + std::string absolutePath; + bool fileExists = false; }; -// The outcome of assembling the drag's path list: the de-duped, existing-only absolute -// paths to hand to the OS, plus explicit tallies so the shell can decide whether to -// initiate at all (an empty `paths` means nothing draggable — do NOT start a drag). +// Outcome of assembling the drag's path list. An empty `paths` means nothing draggable — do not +// start a drag. struct PathList { - std::vector paths; // de-duped, existing files, in first-seen order - int skippedMissing = 0; // resolved but file did not exist (skip policy) - int skippedUnresolved = 0; // shell could not resolve a path at all + std::vector paths; // de-duped, existing files, first-seen order + int skippedMissing = 0; // resolved but file doesn't exist (stale index entry) + int skippedUnresolved = 0; // shell couldn't resolve a path at all int skippedDuplicate = 0; // same absolute path seen more than once }; -// Assembles the drag path list from the resolved samples (in selection order). -// Policy (all explicit, all tested): -// * 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). +// Assembles the drag path list from the resolved samples (selection order). Comparison is +// exact-string — the shell normalizes case/slashes upstream if it wants Windows-style dedup. PathList assemblePathList(const std::vector& resolved); } // namespace reasampler::ui diff --git a/src/core/ui/footer_bar.cpp b/src/core/ui/footer_bar.cpp index e4b683f..7094938 100644 --- a/src/core/ui/footer_bar.cpp +++ b/src/core/ui/footer_bar.cpp @@ -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" @@ -6,8 +6,7 @@ namespace reasampler::ui { namespace { -// True iff a box [x, x+width) fits entirely left of `rightBound` (its right edge does not -// cross the reserved right region). A non-positive width never "fits" (nothing to place). +// True iff a box [x, x+width) fits entirely left of `rightBound`. bool fitsLeftOf(int x, int width, int 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; if (boxH <= 0) return out; - // The right bound the LEFT group must stay clear of (prune + version region). Clamp so a - // pathologically large rightReserve never yields a negative bound. + // Clamp so a pathologically large rightReserve never yields a negative bound. int rightBound = footer.x + footer.width - spec.rightReserve; if (rightBound < footer.x) rightBound = footer.x; diff --git a/src/core/ui/footer_bar.h b/src/core/ui/footer_bar.h index fb608ca..1064b5f 100644 --- a/src/core/ui/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -1,81 +1,46 @@ #pragma once #include "core/ui/rect.h" -// footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4 -// footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count -// 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) ------------------------------- +// footer_bar — layout + hit-test for the bank_panel footer's LEFT group: the [Arrange|Design] +// mode toggle, its compact count label, and the Tail button, left-to-right at the footer's left. // +// Affordance order, left -> right: // [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 -// panel/capture behaves" cluster; Prune stays isolated at the far RIGHT, warn-colored and -// set apart (it is the only byte-deleting affordance). This module lays out the LEFT group -// 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. +// The toggle here is only the overall BOX; the shell hands its width to mode_switch +// (computeSegmentRects / hitTestSegment) for per-segment tiling — mode_switch stays the one +// owner of segment geometry. -#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 { -// One placed affordance's pixel rectangle within the footer, top-left origin. A zero-area rect -// (empty()) means "not placed" (the footer was too narrow to host it after the ones before it), -// so the shell draws/hit-tests nothing for it — graceful degradation, mirroring prune_button. -using FooterBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// One placed affordance's rect, top-left origin. empty() means "not placed" (footer too narrow +// after earlier affordances claimed their space) — shell draws/hit-tests nothing for it. +using FooterBarRect = Rect; -// The laid-out footer LEFT group: the mode toggle box, the count label box, and the Tail -// button box, in left-to-right order. Any box may be empty (suppressed) when the footer is -// too narrow to fit it left of the reserved right margin — placement is greedy left-to-right, -// 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). +// The laid-out footer LEFT group. Any box may be empty when the footer is too narrow to fit it +// left of the reserved right margin; placement is greedy left-to-right (toggle survives longest, +// Tail drops first on a very narrow footer). struct FooterBarLayout { - FooterBarRect toggle; // the [Arrange|Design] segmented control's overall box - FooterBarRect count; // the compact per-mode count label (right of the toggle) - FooterBarRect tail; // the Tail button (right of the count label) + FooterBarRect toggle; + FooterBarRect count; + FooterBarRect tail; bool operator==(const FooterBarLayout& o) const { 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 -// affordance). Prune is NOT here — the shell hit-tests it separately via hitTestPruneButton. +// Which footer LEFT-group affordance a point landed on. Prune is hit-tested separately via +// hitTestPruneButton. enum class FooterHit { None, Toggle, Tail }; -// Layout inputs for the footer LEFT group, in pixels. Defaults are the bank_panel footer -// metrics; the shell passes its own so draw and hit-test share ONE source of truth. -// * toggleWidth — the [Arrange|Design] toggle's overall width. Sized to fit its two -// 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. +// Layout inputs, in pixels; defaults are the bank_panel footer metrics. +// * rightReserve — pixels reserved at the footer's right for the prune button + version +// readout; footer_bar never places an affordance whose right edge would cross into it. struct FooterBarSpec { int toggleWidth = 132; int countWidth = 64; @@ -86,21 +51,14 @@ struct FooterBarSpec { 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 -// count label, then the Tail button, each `gap` px apart, starting at footer.left + leftPad, -// vertically centred by verticalInset. Greedy: an affordance is placed only if its whole box -// fits left of (footer.right - rightReserve); otherwise it (and, since placement is ordered, -// 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). +// Lays out the footer LEFT group inside `footer` per `spec`: toggle, count label, Tail button, +// each `gap` px apart from footer.left + leftPad. Greedy — an affordance places only if it fits +// left of (footer.right - rightReserve); once one doesn't fit, the rest are suppressed too. +// countWidth <= 0 suppresses the count label without leaving a gap for the Tail button. FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec); -// The footer LEFT-group affordance the point (px, py) (SWELL/LICE top-left client coords) lands -// on, or FooterHit::None for a miss (outside every placed box, or on the count label — which is -// 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. +// The affordance (px, py) lands on, or FooterHit::None for a miss (or a hit on the count label, +// a passive readout, never a control). Half-open bounds match computeFooterBar. FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout); } // namespace reasampler::ui diff --git a/src/core/ui/mode_enable.cpp b/src/core/ui/mode_enable.cpp index 0f31bd8..b06ef01 100644 --- a/src/core/ui/mode_enable.cpp +++ b/src/core/ui/mode_enable.cpp @@ -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/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 { 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 = (target == TagTarget::Arrange) ? kArrangeModeId : kDesignModeId; - // Fail-open on an unrecognized active id (neither seed mode): every button live, so a - // future added mode never dead-locks the bar and the user can always reach the action. + // Fail-open on an unrecognized active id: every button live. if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true; return activeModeId != targetId; diff --git a/src/core/ui/mode_enable.h b/src/core/ui/mode_enable.h index 0132f5e..16662bf 100644 --- a/src/core/ui/mode_enable.h +++ b/src/core/ui/mode_enable.h @@ -1,39 +1,22 @@ #pragma once -// mode_enable — the REAPER-free opposite-mode enablement predicate behind the bank_panel BOTTOM -// toolbar's four Item/Track × Arrange/Design tag buttons (Phase L, L5, refinement 3). Each tag -// button sends the selection to a TARGET mode; a button is meaningful ONLY when its target is -// 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. +// mode_enable — enablement predicate behind the bank_panel bottom toolbar's four Item/Track x +// Arrange/Design tag buttons. A tag button sends the selection to a TARGET mode; it's live only +// when its target differs from the currently active mode (you tag INTO the mode you're not in). #include namespace reasampler::ui { -// A tag button's TARGET mode — the mode it sends the selection to when fired. Arrange = the -// untagged default (returning the selection to Arrange), Design = tagged into the Design mode. -// 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. +// A tag button's TARGET mode. The Item/Track axis is orthogonal to enablement (both buttons for +// a target enable/disable together), so it isn't modelled here — the shell carries it per button. enum class TagTarget { Arrange, Design, }; -// True iff a tag button whose target is `target` should be LIVE (clickable), given the active -// mode id `activeModeId` (as returned by ViewModeModel::activeModeId() — the mode ids are the -// pure `kArrangeModeId` / `kDesignModeId` constants). The rule: a button is live iff its target -// 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. +// True iff a button targeting `target` should be live, given the active mode id `activeModeId` +// (ViewModeModel::activeModeId(), i.e. kArrangeModeId / kDesignModeId). An unrecognized active id +// leaves every button live (fail-open — never silently disable a reachable action). bool tagButtonEnabled(const std::string& activeModeId, TagTarget target); } // namespace reasampler::ui diff --git a/src/core/ui/overflow_menu.cpp b/src/core/ui/overflow_menu.cpp index e6dd9b6..dbe1c4b 100644 --- a/src/core/ui/overflow_menu.cpp +++ b/src/core/ui/overflow_menu.cpp @@ -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" @@ -6,8 +6,7 @@ namespace reasampler::ui { int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) { 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 - // (also rightInset) so the frequent buttons have breathing room before the menu button. + // Button width plus a right gap and a matching left gap for breathing room. 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 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; height = bar.height; } diff --git a/src/core/ui/overflow_menu.h b/src/core/ui/overflow_menu.h index 31972ee..6ad993d 100644 --- a/src/core/ui/overflow_menu.h +++ b/src/core/ui/overflow_menu.h @@ -1,44 +1,23 @@ #pragma once #include "core/ui/rect.h" -// overflow_menu — the REAPER-free layout math behind the bank_panel TOP toolbar's "⋯ / More" -// overflow-menu button (Phase L, L5, refinement 1). The rare capture variants (Batch Items / -// Batch Razor / Capture RT) move OFF the always-visible top bar into a popup opened by a small -// square button pinned to the FAR RIGHT of the top toolbar band. This module owns two things, -// both unit-tested outside the DAW: -// * 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. +// overflow_menu — layout for the bank_panel top toolbar's "..." overflow-menu button: the rare +// capture variants (Batch Items / Batch Razor / Capture RT) live in a popup opened by a small +// square button right-anchored in the top toolbar band. Owns the button's placement and the +// horizontal reserve action_bar must leave so its buttons never run under it. The popup itself +// (TrackPopupMenu) and command dispatch are shell concerns. namespace reasampler::ui { -// The toolbar band the button is drawn into, top-left origin (SWELL/LICE convention). The -// shell derives this from topToolbarRect(). A distinct type from action_bar::ActionBarRect so -// 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 toolbar band the button draws into, top-left origin. +using MenuBarRect = Rect; -// The More button's pixel rectangle within the band, top-left origin. A zero-area rect -// (width <= 0 or height <= 0) means "no button" — the band is degenerate or too narrow to -// place the button clear of its left inset; the caller must not draw or hit-test it. The -// 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 +// The More button's rect. Zero-area means "no button" — the three variants stay reachable via +// their bindable commands regardless. +using MenuButtonRect = Rect; -// Layout inputs for the More button, in pixels. Defaults match the bank_panel top-toolbar -// metrics; the shell passes its own so draw and hit-test share one source of truth. -// * buttonWidth — the button's fixed width (a compact square-ish glyph button). -// * 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). +// Layout inputs, in pixels; defaults match the bank_panel top-toolbar metrics. +// * minLeftInset — button's left edge must stay at least this far from the band's left edge; +// otherwise computeMenuButton suppresses it (empty rect). struct MenuButtonSpec { int buttonWidth = 28; int rightInset = 6; @@ -46,23 +25,16 @@ struct MenuButtonSpec { int minLeftInset = 40; }; -// The horizontal reserve (px) the action_bar must leave at the band's right so its buttons -// never run under the More button: the button width + both insets (right gap + a matching -// 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). +// Horizontal reserve (px) action_bar must leave at the band's right: button width + both insets. +// 0 for a degenerate band. int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec); -// Computes the More button's rect within `bar` per `spec`. Right-anchored: the button's right -// edge is bar.x + bar.width - rightInset, its width is buttonWidth, vertically centred by -// verticalInset. Returns an EMPTY rect when: the band is degenerate (width/height <= 0), the -// 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). +// The More button's rect within `bar`, right-anchored, vertically centred by verticalInset. +// Empty when the band is degenerate, buttonWidth <= 0, or the left edge would fall closer to the +// band's left than minLeftInset. A thin band clamps height to the band's own rather than negative. 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 [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). +// Half-open bounds, matching computeMenuButton. Empty button claims nothing. bool hitTestMenuButton(int px, int py, const MenuButtonRect& button); } // namespace reasampler::ui diff --git a/src/core/ui/prune_button.cpp b/src/core/ui/prune_button.cpp index 71db3a2..7136d49 100644 --- a/src/core/ui/prune_button.cpp +++ b/src/core/ui/prune_button.cpp @@ -1,9 +1,6 @@ #include "core/ui/prune_button.h" -// prune_button implementation — right-anchored button placement in the footer strip, -// 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. +// prune_button — pure implementation. See prune_button.h. namespace reasampler::ui { diff --git a/src/core/ui/prune_button.h b/src/core/ui/prune_button.h index cf7f853..c2f6343 100644 --- a/src/core/ui/prune_button.h +++ b/src/core/ui/prune_button.h @@ -1,82 +1,38 @@ #pragma once #include "core/ui/rect.h" -// prune_button — the REAPER-free layout math behind the bank_panel's Prune button -// (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's -// 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. +// prune_button — layout for the bank_panel's Prune button in the tail-footer strip. Panel shell +// owns SWELL/LICE/dispatch; this owns whether a click lands on it. // -// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER. -// -// -- Placement contract -------------------------------------------------------- -// -// 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. +// Placement: right-anchored in the footer, inset from the right edge, just left of the version +// 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 +// reachable via its binding either way. namespace reasampler::ui { -// The footer strip the button is drawn into, top-left origin (SWELL/LICE -// convention). (x, y) is the top-left corner; width/height are the strip extents. -// 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 +using FooterRect = Rect; +using ButtonRect = Rect; -// A button's pixel rectangle within the footer, top-left origin. A zero-area rect -// (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place -// it, or the footer itself is degenerate; the caller must not draw or hit-test it. -using ButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased - -// Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer -// metrics; the shell passes its own so draw and hit-test share one source of truth. -// * buttonWidth — the button's fixed width. -// * 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). +// Layout inputs, in pixels; defaults match the bank_panel footer metrics. +// * rightInset — gap from the footer's right edge to the button's right edge, clearing the +// right-aligned version readout. COUPLED to drawFooter's version-readout +// margin (panel_render.cpp) and to FooterBarSpec::rightReserve, which must +// exceed rightInset + buttonWidth so the left group never runs under this +// button. Update together if either margin changes. +// * minLeftInset — button's left edge must stay this far from the footer left edge (room for +// the footer-left group); otherwise the button is suppressed. struct PruneButtonSpec { int buttonWidth = 72; - int rightInset = 84; // COUPLED: version readout in drawFooter uses an 8 px right margin + int rightInset = 84; int verticalInset = 4; int minLeftInset = 120; }; -// Computes the prune button's rect within `footer` per `spec`. Right-anchored: the -// button's right edge is footer.x + footer.width - rightInset, its width is buttonWidth, -// 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. +// Right-anchored rect within `footer`, vertically centred. Empty when the footer is degenerate +// or the resulting left edge would fall closer to the footer's left than minLeftInset. 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 [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. +// Half-open bounds, matching computePruneButton. Empty button never claims a point. bool hitTestPruneButton(int px, int py, const ButtonRect& button); } // namespace reasampler::ui diff --git a/src/core/ui/rect.h b/src/core/ui/rect.h index 92e64bd..d5b48ac 100644 --- a/src/core/ui/rect.h +++ b/src/core/ui/rect.h @@ -1,23 +1,6 @@ #pragma once -// rect.h — the ONE concrete pixel rectangle (Q-W1, T2-05 ≡ T4-21). -// -// 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. +// 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. namespace reasampler::ui { @@ -27,16 +10,13 @@ struct Rect { int width = 0; int height = 0; - // Exclusive edges (half-open convention). int right() const { return x + width; } int bottom() const { return y + height; } - // A zero-or-negative-area rect means "not placed / suppressed": the caller must - // not draw or hit-test it (the shared graceful-degradation contract). + // Zero-or-negative area means "not placed / suppressed" — caller must not draw or hit-test it. bool empty() const { return width <= 0 || height <= 0; } - // The former LTRB grammar's constructor (editor_geometry and friends): edges in, - // extents stored. right/bottom exclusive, matching right()/bottom(). + // LTRB constructor for call sites that think in edges rather than extents. static Rect ltrb(int left, int top, int right, int bottom) { return Rect{left, top, right - left, bottom - top}; } @@ -47,8 +27,8 @@ struct Rect { bool operator!=(const Rect& o) const { return !(*this == o); } }; -// True iff (px, py) falls inside r under the half-open convention. An empty rect -// contains nothing, so a suppressed affordance can never claim a click. +// Half-open containment; an empty rect contains nothing, so a suppressed affordance never +// claims a click. 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; } diff --git a/src/core/ui/tab_strip.cpp b/src/core/ui/tab_strip.cpp index c0d4db7..b05de6b 100644 --- a/src/core/ui/tab_strip.cpp +++ b/src/core/ui/tab_strip.cpp @@ -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" @@ -8,17 +8,16 @@ namespace reasampler::ui { TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, 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; if (tabCount <= 0 || strip.width <= 0) { out.trackX = strip.x; out.trackWidth = strip.width > 0 ? strip.width : 0; - return out; // nothing to lay out: track == strip, no overflow, no chevrons + return out; } const int totalTabsWidth = tabCount * spec.tabWidth; if (totalTabsWidth <= strip.width) { - // Everything fits: the whole strip is the track; no chevrons, no scroll. out.overflow = false; out.trackX = strip.x; out.trackWidth = strip.width; @@ -26,15 +25,12 @@ TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, return out; } - // Overflow: reserve a chevron band at each end; the tabs live between them. out.overflow = true; out.leftChevron = true; out.rightChevron = true; out.trackX = strip.x + spec.chevronWidth; out.trackWidth = strip.width - 2 * spec.chevronWidth; if (out.trackWidth < 0) out.trackWidth = 0; - // The tab run exceeds the track by this many pixels; the strip may scroll exactly - // that far so the last tab's right edge reaches the track's right edge, no more. out.maxScroll = totalTabsWidth - out.trackWidth; if (out.maxScroll < 0) out.maxScroll = 0; return out; @@ -61,11 +57,9 @@ std::vector computeTabRects(const TabStripRect& strip, int tabCount, for (int i = 0; i < tabCount; ++i) { const int rawLeft = trackLeft + i * spec.tabWidth - offset; const int rawRight = rawLeft + spec.tabWidth; - // Clip to the track: a partially-scrolled tab must not draw under a chevron - // or spill past the track. A tab whose clipped extent is empty is omitted. int left = rawLeft < trackLeft ? trackLeft : rawLeft; int right = rawRight > trackRight ? trackRight : rawRight; - if (right <= left) continue; // fully scrolled out of view either side + if (right <= left) continue; // fully scrolled out of view TabRect r; r.index = i; r.x = left; @@ -79,10 +73,9 @@ std::vector computeTabRects(const TabStripRect& strip, int tabCount, TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset) { - TabHit miss; // {None, -1} + TabHit 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 || py < strip.y || py >= strip.y + strip.height) return miss; @@ -90,8 +83,7 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripLayout layout = computeTabStripLayout(strip, tabCount, spec, scrollOffset); - // Chevrons take precedence at the strip ends: a click in a reserved chevron band - // is a scroll, never a tab (the tab track excludes those bands). + // Chevron bands take precedence at the strip ends over any tab. if (layout.overflow) { if (px < strip.x + spec.chevronWidth) 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}; } - // Inside the track: find the visible tab whose clipped rect contains px. Reuse - // computeTabRects so the hit matches exactly what was drawn (clipping included). + // Reuse computeTabRects so the hit matches exactly what was drawn (clipping included). const std::vector rects = computeTabRects(strip, tabCount, spec, scrollOffset); for (const TabRect& r : rects) { if (px >= r.x && px < r.x + r.width) return TabHit{TabHitKind::Tab, r.index}; } - return miss; // track dead space (no tab under the point) + return miss; } } // namespace reasampler::ui diff --git a/src/core/ui/tab_strip.h b/src/core/ui/tab_strip.h index ea11b38..8e6f738 100644 --- a/src/core/ui/tab_strip.h +++ b/src/core/ui/tab_strip.h @@ -1,45 +1,25 @@ #pragma once #include "core/ui/rect.h" -// tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's -// named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the -// vertical-split bank window is a LICE-drawn tab strip (one tab per named bank, -// NOT a SWELL-native tab control), and — from the start — it must scroll when the -// tabs overflow the strip width (a naive fixed-width strip breaks down at ~8–12 -// tabs). What is NOT DAW-bound — how N fixed-width tabs tile a strip of a given -// pixel width, where the overflow chevrons sit, which tab/chevron a click lands in, -// and how far the strip may scroll — lives here so it is unit-tested outside the -// DAW (CLAUDE.md §load-bearing split). The panel shell (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. +// tab_strip — layout + hit-test for the bank_panel's named-banks tab strip: a LICE-drawn strip +// (not a SWELL tab control) that scrolls via chevrons when tabs overflow the strip width. #include namespace reasampler::ui { -// The strip the tabs are drawn into, top-left origin (SWELL/LICE convention). -// (x, y) is the top-left corner; width/height are the strip extents. The panel -// reserves this as a fixed-height band at the top of the named-banks region. -using TabStripRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// The strip the tabs draw into, top-left origin. +using TabStripRect = Rect; -// Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed -// so the strip reads as a uniform segmented control and overflow math stays simple — -// labels ellipsize within the tab, they do not resize it). chevronWidth is the width -// reserved at each end for the scroll affordance WHEN the tabs overflow; when they -// fit, no chevron is reserved and the tabs use the full strip width. +// tabWidth is fixed per tab so the strip reads as a uniform segmented control and overflow math +// stays simple (labels ellipsize, they don't resize the tab). chevronWidth is reserved at each +// end only when tabs overflow. struct TabStripSpec { int tabWidth = 96; int chevronWidth = 20; }; -// One tab's pixel rectangle within the strip, top-left origin, ALREADY translated -// by the current scroll offset and clipped to the visible track. `index` is the -// tab's index in the caller's list (ordinal order) so the shell can label/​light it -// without re-deriving. A tab scrolled fully out of view is omitted from the result -// (the shell only draws what computeTabRects returns), so every returned rect is at -// least partially visible. +// One tab's rect, already translated by scroll offset and clipped to the visible track. A tab +// scrolled fully out of view is omitted from computeTabRects's result. struct TabRect { int index = 0; int x = 0; @@ -53,59 +33,41 @@ struct TabRect { } }; -// The scrollable track's geometry: where the tabs may be drawn (between the -// chevrons when overflowing, or the whole strip when they fit) and whether each -// chevron is present. Derived once and shared by layout + hit-testing so both agree. +// Scrollable track geometry, shared by layout + hit-test so both agree. struct TabStripLayout { - bool overflow = false; // true iff N tabs at tabWidth exceed the track width - int trackX = 0; // left edge of the tab track (past the left chevron) - int trackWidth = 0; // width available to tabs (strip minus both chevrons) - int maxScroll = 0; // largest valid scroll offset (0 when no overflow) - bool leftChevron = false; // a left-scroll affordance is reserved this frame - bool rightChevron = false;// a right-scroll affordance is reserved this frame + bool overflow = false; + int trackX = 0; + int trackWidth = 0; + int maxScroll = 0; + bool leftChevron = false; + bool rightChevron = false; }; -// Computes the strip layout for `tabCount` tabs of `spec.tabWidth` in `strip`, -// given the current `scrollOffset`. Pure geometry: -// * No overflow (all tabs fit the strip width): overflow=false, no chevrons, the -// track IS the strip, maxScroll=0. -// * Overflow: both chevrons are reserved (chevronWidth each), the track is the -// strip minus both chevrons, and maxScroll is the pixels by which the tab run -// exceeds the track (so the last tab's right edge can reach the track's right -// edge but not scroll past it). Chevrons are always both present under overflow -// (a fixed affordance is simpler and unambiguous than hiding one at an end; -// clicking a chevron at a scroll limit is a harmless no-op the shell clamps). -// tabCount <= 0 or a non-positive strip width returns a zeroed layout (no overflow, -// track == strip, maxScroll 0). +// Layout for `tabCount` tabs of `spec.tabWidth` in `strip`. No overflow: track == strip, no +// chevrons, maxScroll 0. Overflow: both chevrons always reserved together (simpler than hiding +// one at a scroll limit — a chevron click there is a harmless no-op the shell clamps); track is +// the strip minus both chevrons; maxScroll is how far the tab run exceeds the track. +// tabCount <= 0 or non-positive strip width returns a zeroed layout. TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); -// Clamps a desired scroll offset into [0, maxScroll] for the given layout. The shell -// calls this after a chevron click / wheel so the strip never scrolls past either -// end. maxScroll is 0 when the tabs fit, so a fitting strip always clamps to 0. +// Clamps a desired scroll offset into [0, maxScroll]; always 0 when tabs fit. int clampTabScroll(int desiredOffset, const TabStripLayout& layout); -// Tiles `tabCount` fixed-width tabs left-to-right into the layout's track, shifted -// left by `scrollOffset`, and returns the rects that are at least partially visible -// (in tab-index order). Each tab i sits at trackX + i*tabWidth - scrollOffset; a tab -// whose visible extent is empty (fully left of or right of the track) is omitted. -// Returned rects are CLIPPED to the track horizontally so a partially-scrolled tab -// does not draw under a chevron. The caller passes the SAME scrollOffset it passed -// to computeTabStripLayout (the shell clamps once, then uses the clamped value for -// both). tabCount <= 0 -> empty. +// Tiles tabCount fixed-width tabs into the track, shifted by scrollOffset, returning only +// partially-or-fully visible rects (clipped to the track so a scrolled tab never draws under a +// chevron). Caller must pass the same scrollOffset used for computeTabStripLayout. std::vector computeTabRects(const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); -// What a point in the strip resolves to. enum class TabHitKind { - None, // outside the strip, or in dead space between visible tabs - Tab, // a tab — `index` is the tab's index in the caller's list - ScrollLeft, // the left overflow chevron - ScrollRight, // the right overflow chevron + None, + Tab, + ScrollLeft, + ScrollRight, }; -// The outcome of hit-testing a point against the strip. For Tab, `index` is the tab -// index; for the chevrons and None it is -1. +// index is the tab's index for Tab, -1 for chevrons/None. struct TabHit { TabHitKind kind = TabHitKind::None; int index = -1; @@ -115,12 +77,8 @@ struct TabHit { } }; -// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out -// for `tabCount` tabs at `scrollOffset`. Chevrons take precedence over tabs at the -// strip ends (a click in the reserved chevron band is a scroll, never a tab), and a -// point outside the strip band, or in the track but not on any visible tab, is None. -// Half-open bounds match computeTabRects / the chevron bands so no pixel is claimed -// twice. The shell passes the SAME clamped scrollOffset it drew with. +// Hit-tests a point against the strip laid out for `tabCount` tabs at `scrollOffset`. Chevrons +// take precedence at the strip ends. Half-open bounds match computeTabRects. TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); diff --git a/src/core/ui/theme.cpp b/src/core/ui/theme.cpp index e80ee62..97d95f9 100644 --- a/src/core/ui/theme.cpp +++ b/src/core/ui/theme.cpp @@ -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" @@ -10,59 +10,48 @@ namespace reasampler::ui { namespace { // =========================================================================== -// THE ONE DIRECTION CONSTANTS BLOCK (DS-2 revised: B "Neon Console" REAPER-grey -// neutrals + three-accent pastel system + C pastel spectral). -// -// This is the SINGLE POINT OF CHANGE. Every role color below is one of these -// constants; roleColor() is a pure switch over them. To re-pick the visual -// direction (§4: A Studio Rack / B Neon Console / C full spectral), edit THIS -// 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). +// THE ONE DIRECTION CONSTANTS BLOCK. Every role color below is one of these constants; +// 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): +// 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"). // =========================================================================== -// REAPER-theme mid-grey elevation stack (DS-2 revised — NOT near-black). Matches -// Daniel's REAPER theme so the dock reads as part of REAPER: base = window chrome -// grey, panel/cell one step lighter each. The elevation-ladder discipline is -// unchanged (base < panel < cell by a few %, micro-gradient + inner highlight/ -// shadow carry elevation, not hard borders); only the VALUES moved up into grey. +// REAPER-theme mid-grey elevation stack, matching Daniel's REAPER theme so the dock reads as +// part of REAPER: base = window chrome grey, panel/cell one step lighter each. Elevation-ladder +// discipline: base < panel < cell by a few %, micro-gradient + inner highlight/shadow carry +// elevation, not hard borders. constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg 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 -// grey secondary. The greyer surfaces shrank the dim cushion (mid-grey-on-mid-grey -// is the classic AA failure): the spec-start #a0a0a0 lands ~4.35:1 on bg/cell, UNDER -// the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1 on bg/cell), the lightest grey -// that still reads dim while clearing AA 4.5 body on the greyest surface it draws -// body text on. Locked by test_theme.cpp. +// Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer grey +// secondary. Mid-grey-on-mid-grey is the classic AA failure: the spec-start #a0a0a0 lands +// ~4.35:1 on bg/cell, under the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1), the lightest +// grey that still reads dim while clearing the floor. Locked by test_theme.cpp. constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0) -// The three-accent pastel system (DS-2 revised — replaces the single electric cyan). -// primary = pastel lime (the live/active/selected signal, the eye-magnet); secondary -// = 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 the -// greyer bg/cell the pastels clear the 3:1 indicator floor comfortably (primary ~7.6, -// secondary ~6.8, tertiary ~5.5) at the spec-start values, so no per-hue nudge was -// needed — the hues stay pastel lime/teal/purple. warn is a reserved red/amber for -// byte-deleting states only. +// Three-accent pastel system: primary = pastel lime (the live/active/selected signal); +// secondary = 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 +// pastels clear the 3:1 indicator floor comfortably at these values (primary ~7.6, secondary +// ~6.8, tertiary ~5.5), so no per-hue nudge was needed. warn is reserved for byte-deleting +// states only. constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only -// Direction C pastel spectral ramp (DS-2 revised): a three-stop sweep through the -// accents — pastel lime (low) -> pastel teal (mid) -> pastel purple (high) — so the -// signature keyboard strip reads as an extension of the accent system, not a neon -// flourish. Endpoints/midpoint ARE the three accent constants (single source). -constexpr KitColor kDirSpectralLo = kDirAccentPrimary; // low notes: pastel lime -constexpr KitColor kDirSpectralMid = kDirAccentSecondary; // mid notes: pastel teal -constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // high notes: pastel purple +// Spectral ramp: pastel lime (low) -> pastel teal (mid) -> pastel purple (high). Endpoints and +// midpoint ARE the three accent constants (single source), so the keyboard strip reads as an +// extension of the accent system. +constexpr KitColor kDirSpectralLo = kDirAccentPrimary; +constexpr KitColor kDirSpectralMid = kDirAccentSecondary; +constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // --- state transform helpers ------------------------------------------------- @@ -70,8 +59,8 @@ std::uint8_t clamp8(int v) { return static_cast(v < 0 ? 0 : (v > 255 ? 255 : v)); } -// Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state -// tint changes hue/brightness, not opacity; disabled handles alpha separately). +// Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state tint changes +// hue/brightness, not opacity; disabled handles alpha separately). KitColor mix(const KitColor& a, const KitColor& b, double t) { return KitColor{ clamp8(static_cast(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) { return KitColor{ clamp8(static_cast(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]. 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( std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b))); const KitColor g{static_cast(gray), @@ -132,25 +119,20 @@ KitColor roleColorState(Role role, InteractionState state) { case InteractionState::Rest: return base; 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); case InteractionState::Active: - // The selected/active layer carries the PRIMARY accent — "this is live" - // is always the primary hue (DS-2 revised: primary leads state; secondary/ - // tertiary are categorical, never intensity). + // "This is live" is always the primary hue — secondary/tertiary stay categorical. return roleColor(Role::AccentPrimary); case InteractionState::Pressed: - // The surface "pushes in": darken. - return scale(base, 0.82); + return scale(base, 0.82); // the surface "pushes in" case InteractionState::Dragging: - // A live-drag element reads as active-but-lighter (primary -> hot). return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30); case InteractionState::Focus: - // Focus keeps the surface but is drawn with a text/primary ring by the - // shell; the fill nudges toward the primary accent so focus reads pre-ring. + // Focus keeps the surface; the shell draws a text/primary ring on top, and the + // fill nudges toward the primary accent so focus reads pre-ring. return mix(base, roleColor(Role::AccentPrimary), 0.08); case InteractionState::Disabled: { - // Desaturate and drop alpha to 40% (§3.3). KitColor d = desaturate(base, 0.6); d.a = static_cast(std::lround(base.a * 0.4)); return d; @@ -162,10 +144,8 @@ KitColor roleColorState(Role role, InteractionState state) { KitColor spectralColor(double t) { if (t < 0.0) t = 0.0; if (t > 1.0) t = 1.0; - // Three-stop pastel sweep anchored on the accent trio (DS-2 revised Direction C): - // lime (low) -> teal (mid, t=0.5) -> purple (high). A single Lo->Hi lerp would skip - // 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. + // Interpolate each half separately so the midpoint IS the secondary accent (a single + // Lo->Hi lerp would skip it and drift the ramp off the accent family). if (t <= 0.5) { return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5); } diff --git a/src/core/ui/theme.h b/src/core/ui/theme.h index 0d6f5ba..caaeb2b 100644 --- a/src/core/ui/theme.h +++ b/src/core/ui/theme.h @@ -1,35 +1,21 @@ #pragma once -// theme — the REAPER-free, LICE-free palette + type-scale core of the shared drawing -// kit (Phase L, L1). This is the "one source of drawing" made testable at its root: a -// ROLE-based color model (bg/base, bg/panel, bg/cell, line/hairline, text/primary, -// text/dim, accent/primary, accent/secondary, accent/tertiary, accent/hot, warn), an -// INTERACTION-STATE model (rest/hover/active/pressed/dragging/focus/disabled), and the -// WCAG contrast math that lets a unit test prove every text-on-surface pair clears its -// floor ("punch to the floor, not past it"). +// theme — the palette + type-scale core of the shared drawing kit: a ROLE-based color model +// (bg/base, bg/panel, bg/cell, line/hairline, text/primary, text/dim, accent/primary, +// accent/secondary, accent/tertiary, accent/hot, warn), an interaction-state model +// (rest/hover/active/pressed/dragging/focus/disabled), and the WCAG contrast math that lets a +// unit test prove every text-on-surface pair clears its floor. // -// THE SINGLE POINT OF CHANGE (DS-2 revised): every role color is produced by roleColor() -// from ONE direction constants block (kDirection*, below) carrying the settled B (Neon -// Console) neutrals — now REAPER-theme mid-grey, not near-black — plus the three-accent -// pastel system (primary lime / secondary teal / tertiary purple) and the C pastel -// 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. +// Every role color is produced by roleColor() from ONE direction constants block (theme.cpp) — +// the single point of change; no shell hardcodes a color, it asks by role. The spectral hue ramp +// (spectralColor) lives here too so the keyboard strip derives its per-note hue from the same +// source, anchored on the three accents (primary -> secondary -> tertiary). #include namespace reasampler::ui { -// A straight 8-bit-per-channel RGBA color, LICE-free. The draw shell converts this to a -// LICE_pixel via LICE_RGBA at the boundary (draw_kit); nothing here depends on LICE's -// packing. Deliberately NOT named "Color"/"RGBA" (both are common collision surfaces); -// "KitColor" scopes it to the kit. +// Straight 8-bit-per-channel RGBA, LICE-free; draw_kit converts to LICE_pixel at the boundary. +// Named "KitColor" (not "Color"/"RGBA") to avoid collision. struct KitColor { std::uint8_t r = 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 -// direction (B/C) sets the concrete hue behind each; the shell always asks by role. +// Structural palette roles, direction-independent — the shell always asks by role. enum class Role { BgBase, // window canvas BgPanel, // a raised region (list, waveform pane) @@ -50,69 +35,57 @@ enum class Role { LineHairline, // separators (used sparingly — elevation carries most separation) TextPrimary, // labels, values 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 AccentTertiary,// categorical role B (pastel purple) — a distinct KIND, never intensity AccentHot, // hover / live / drag feedback (a brighter tint OF the primary accent) Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states }; -// The interaction-state model every kit component honors (§3.3). A component draws its -// role surface transformed by its current state; stateShift() below is that transform. +// Interaction-state model every kit component honors; stateShift (roleColorState) is the +// role-surface transform for the current state. enum class InteractionState { Rest, Hover, - Active, // selected / active + Active, Pressed, Dragging, Focus, Disabled, }; -// Text size classes for the WCAG floor. "Large" text (>= ~18.66px, or >= ~14px bold) and -// UI-state indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA). The kit's -// four cached fonts map onto these: title -> Large, label/value -> Body, micro -> Body. +// Text size classes for the WCAG floor: "Large" (>= ~18.66px, or >= ~14px bold) and UI-state +// indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA). enum class TextClass { Body, // AA 4.5:1 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 single choke point the "single point of change" guarantee rests on: the shell has -// no other way to obtain a palette color, so re-picking the direction is editing the -// kDirection* block this reads and nothing else. +// The concrete color for a role, from the one direction constants block — the single choke +// point re-picking the direction touches. KitColor roleColor(Role role); -// The color for a role under an interaction state — roleColor(role) transformed by the -// state (hover lightens toward accent/hot, pressed darkens, disabled desaturates + drops -// alpha, etc.). Surfaces use this so every component gets the whole state model for free. -// Rest returns roleColor(role) unchanged. +// roleColor(role) transformed by state (hover lightens toward accent/hot, pressed darkens, +// disabled desaturates + drops alpha, etc). Rest returns roleColor(role) unchanged. KitColor roleColorState(Role role, InteractionState state); -// Direction C's spectral hue ramp (DS-2 revised — a PASTEL sweep anchored on the three -// accents, not the old neon cool-blue -> hot-magenta): maps a normalized position t in -// [0, 1] (low note -> high note across the keyboard strip) to a color that runs -// accent/primary (pastel lime, low) -> accent/secondary (pastel teal, mid) -> -// 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]. +// Spectral hue ramp for the keyboard strip: maps normalized position t in [0, 1] (low note -> +// high note) through accent/primary (low) -> accent/secondary (mid) -> accent/tertiary (high), +// so the strip reads as an extension of the accent system rather than a separate flourish. +// t is clamped to [0, 1]. KitColor spectralColor(double t); // --- WCAG contrast (the "punch" rule, made testable) -------------------------- -// -// The relative luminance of a color per WCAG 2.1 (sRGB linearization + the 0.2126/ -// 0.7152/0.0722 weighting). Alpha is ignored — contrast is a question about the opaque -// hues; a translucent overlay's effective color is the caller's to compose first. + +// Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha +// is ignored — a translucent overlay's effective color is the caller's to compose first. 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); -// The contrast floor a text class must clear: 4.5 for Body, 3.0 for Large. The test that -// proves the palette asserts contrastRatio(text, surface) >= textFloor(class) for every -// pair the kit actually draws. +// Contrast floor a text class must clear: 4.5 for Body, 3.0 for Large. test_theme.cpp asserts +// contrastRatio(text, surface) >= textFloor(class) for every pair the kit actually draws. double textFloor(TextClass cls); } // namespace reasampler::ui diff --git a/src/core/ui/tooltip.cpp b/src/core/ui/tooltip.cpp index 5f09110..52f34f7 100644 --- a/src/core/ui/tooltip.cpp +++ b/src/core/ui/tooltip.cpp @@ -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" diff --git a/src/core/ui/tooltip.h b/src/core/ui/tooltip.h index 1682597..4e7ce55 100644 --- a/src/core/ui/tooltip.h +++ b/src/core/ui/tooltip.h @@ -1,22 +1,14 @@ #pragma once -// tooltip — the REAPER-free layout math + text helper behind the bank_panel's custom hover-delay -// tooltip (Phase L, L5, refinement 2). Button FACES stay short (the terse shortLabel); hovering a -// button for a short delay pops a small tooltip carrying the FULL action name with the -// "ReaSampler:" display prefix stripped. The tooltip is a custom LICE-kit draw (NOT the native -// 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. +// tooltip — layout math + text helper behind the bank_panel's custom hover-delay tooltip. Button +// faces stay short; hovering pops a small tooltip with the full action name, "ReaSampler:" +// display prefix stripped. Custom LICE-kit draw, not the native Win32/SWELL tooltip control, for +// cross-platform uniformity with the rest of the kit. #include namespace reasampler::ui { -// The tooltip's box (top-left origin, SWELL/LICE convention). A zero-area rect means "do not -// draw" (degenerate inputs); the caller checks empty() before drawing. +// Zero-area means "do not draw" (degenerate inputs); caller checks empty() first. struct TooltipBox { int x = 0; int y = 0; @@ -30,10 +22,8 @@ struct TooltipBox { } }; -// Placement inputs, in pixels. -// * gap — vertical gap between the anchor button and the tooltip box. -// * padX/padY — horizontal / vertical text padding inside the box. -// * margin — minimum clearance kept from the client edges when clamping. +// gap: vertical gap between anchor button and tooltip. padX/padY: text padding inside the box. +// margin: minimum clearance from client edges when clamping. struct TooltipSpec { int gap = 4; int padX = 6; @@ -41,20 +31,15 @@ struct TooltipSpec { int margin = 2; }; -// Strips the action DISPLAY PREFIX from a full action name for the tooltip face. The registered -// gaccel name is composed as `prefix + phrase` (prefix from actionDisplayPrefix(), e.g. -// "ReaSampler: "); the tooltip shows only the phrase. If `fullName` does not start with -// `prefix`, it is returned unchanged (defensive — a name from an unexpected source still shows). -// An empty prefix returns fullName unchanged. +// Strips the action display prefix (e.g. "ReaSampler: ") from a full action name for the +// tooltip face. If fullName doesn't start with prefix, returned unchanged (defensive). Empty +// prefix returns fullName unchanged. 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 -// (anchorX, anchorY, anchorW, anchorH), clamped inside the client rect (0,0,clientW,clientH). -// Preference: BELOW the anchor, horizontally centred on it. If it would clip the bottom edge, -// it flips ABOVE the anchor. It is then clamped horizontally (and vertically as a last resort) -// 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). +// Places a tooltip of size (textW + 2*padX) x (textH + 2*padY) for the anchor button rect, +// clamped inside the client rect. Prefers BELOW the anchor, centered; flips ABOVE if it would +// clip the bottom edge, then clamps to stay within `margin` of the client edges. Empty when the +// text extent or client is degenerate. textW/textH are measured by the shell before calling. TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, int textW, int textH, int clientW, int clientH, const TooltipSpec& spec);