Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// action_bar — pure implementation. See action_bar.h. NO REAPER / SWELL / LICE / vendor.
|
||||
|
||||
#include "core/ui/action_bar.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// The total button count across all clusters (empty clusters contribute nothing).
|
||||
int totalButtons(const std::vector<ClusterSpec>& clusters) {
|
||||
int n = 0;
|
||||
for (const ClusterSpec& c : clusters)
|
||||
if (c.count > 0) n += c.count;
|
||||
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 innerX = s.x + hpad;
|
||||
const int innerW = s.width - 2 * hpad;
|
||||
if (innerW <= 0) return; // too narrow for text; leave label rect empty
|
||||
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.
|
||||
std::vector<ActionBarSlot> tile(const ActionBarRect& bar,
|
||||
const std::vector<ClusterSpec>& clusters,
|
||||
const ActionBarSpec& spec, int visible) {
|
||||
std::vector<ActionBarSlot> slots;
|
||||
if (visible <= 0) return slots;
|
||||
slots.reserve(static_cast<std::size_t>(visible));
|
||||
|
||||
const int top = bar.y + spec.verticalInset;
|
||||
const int btnH = bar.height - 2 * spec.verticalInset;
|
||||
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`)
|
||||
bool firstClusterEmitted = false;
|
||||
|
||||
for (const ClusterSpec& c : clusters) {
|
||||
if (c.count <= 0) continue; // skip empty clusters (no gap emitted)
|
||||
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
|
||||
|
||||
ActionBarSlot s;
|
||||
s.index = flatIndex;
|
||||
s.cluster = c.cluster;
|
||||
s.x = cursorX;
|
||||
s.y = top;
|
||||
s.width = spec.buttonWidth;
|
||||
s.height = btnH;
|
||||
fillTextRects(s, spec);
|
||||
slots.push_back(s);
|
||||
|
||||
cursorX += spec.buttonWidth;
|
||||
++placed;
|
||||
}
|
||||
}
|
||||
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.
|
||||
int rightEdgeFor(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
|
||||
const ActionBarSpec& spec, int visible) {
|
||||
if (visible <= 0) return bar.x + spec.sidePad;
|
||||
int cursorX = bar.x + spec.sidePad;
|
||||
int placed = 0;
|
||||
bool firstClusterEmitted = false;
|
||||
for (const ClusterSpec& c : clusters) {
|
||||
if (c.count <= 0) continue;
|
||||
if (placed >= visible) break;
|
||||
if (firstClusterEmitted) cursorX += spec.clusterGap;
|
||||
firstClusterEmitted = true;
|
||||
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
|
||||
++placed;
|
||||
if (placed >= visible) return cursorX;
|
||||
}
|
||||
}
|
||||
return cursorX;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
|
||||
const ActionBarSpec& spec) {
|
||||
BarFit fit;
|
||||
const int total = totalButtons(clusters);
|
||||
if (total <= 0 || bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) {
|
||||
fit.hiddenCount = total > 0 ? total : 0;
|
||||
return fit;
|
||||
}
|
||||
|
||||
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)
|
||||
visible = cand;
|
||||
else
|
||||
break;
|
||||
}
|
||||
fit.visibleCount = visible;
|
||||
fit.hiddenCount = total - visible;
|
||||
return fit;
|
||||
}
|
||||
|
||||
std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
|
||||
const std::vector<ClusterSpec>& clusters,
|
||||
const ActionBarSpec& spec) {
|
||||
if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return {};
|
||||
const BarFit fit = computeBarFit(bar, clusters, spec);
|
||||
return tile(bar, clusters, spec, fit.visibleCount);
|
||||
}
|
||||
|
||||
int hitTestActionBar(int px, int py, const ActionBarRect& bar,
|
||||
const std::vector<ClusterSpec>& 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;
|
||||
|
||||
const std::vector<ActionBarSlot> slots = computeBarSlots(bar, clusters, spec);
|
||||
for (const ActionBarSlot& s : slots) {
|
||||
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
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,151 @@
|
||||
#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.
|
||||
|
||||
#include <vector>
|
||||
|
||||
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.
|
||||
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)
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
// 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.
|
||||
struct ActionBarSlot {
|
||||
int index = 0;
|
||||
ActionCluster cluster = ActionCluster::Capture;
|
||||
int x = 0;
|
||||
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).
|
||||
int labelX = 0, labelY = 0, labelW = 0, labelH = 0;
|
||||
|
||||
bool operator==(const ActionBarSlot& o) const {
|
||||
return index == o.index && cluster == o.cluster &&
|
||||
x == o.x && y == o.y && width == o.width && height == o.height &&
|
||||
labelX == o.labelX && labelY == o.labelY &&
|
||||
labelW == o.labelW && labelH == o.labelH;
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
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).
|
||||
struct ActionBarSpec {
|
||||
int buttonWidth = 108;
|
||||
int buttonGap = 4;
|
||||
int clusterGap = 16;
|
||||
int sidePad = 8;
|
||||
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].
|
||||
struct BarFit {
|
||||
int visibleCount = 0; // buttons that fit (laid out), counted from the front
|
||||
int hiddenCount = 0; // total - visibleCount (the overflow, dropped whole)
|
||||
};
|
||||
|
||||
BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& 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.
|
||||
std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
|
||||
const std::vector<ClusterSpec>& 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).
|
||||
int hitTestActionBar(int px, int py, const ActionBarRect& bar,
|
||||
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,227 @@
|
||||
// bank_grid — pure implementation. See bank_grid.h. NO REAPER / SWELL / vendor.
|
||||
|
||||
#include "core/ui/bank_grid.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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.
|
||||
std::vector<int> rangeIndices(int a, int b) {
|
||||
if (a > b) std::swap(a, b);
|
||||
std::vector<int> out;
|
||||
out.reserve(static_cast<std::size_t>(b - a + 1));
|
||||
for (int i = a; i <= b; ++i) out.push_back(i);
|
||||
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};
|
||||
s.focus = index;
|
||||
s.anchor = index;
|
||||
return s;
|
||||
}
|
||||
|
||||
} // 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.
|
||||
const int cell = spec.cellWidth + spec.gap;
|
||||
if (cell <= 0) return 1; // degenerate spec — one column, avoid divide-by-zero
|
||||
const int usable = panelWidth - spec.gap;
|
||||
if (usable < spec.cellWidth) return 1;
|
||||
const int cols = usable / cell;
|
||||
return cols < 1 ? 1 : cols;
|
||||
}
|
||||
|
||||
std::vector<CellRect> computeCellRects(int itemCount,
|
||||
int panelWidth,
|
||||
const GridSpec& spec) {
|
||||
std::vector<CellRect> rects;
|
||||
if (itemCount <= 0) return rects;
|
||||
|
||||
const int cols = columnsForWidth(panelWidth, spec);
|
||||
rects.reserve(static_cast<std::size_t>(itemCount));
|
||||
|
||||
for (int i = 0; i < itemCount; ++i) {
|
||||
const int col = i % cols;
|
||||
const int row = i / cols;
|
||||
CellRect r;
|
||||
r.x = spec.gap + col * (spec.cellWidth + spec.gap);
|
||||
r.y = spec.gap + row * (spec.cellHeight + spec.gap);
|
||||
r.width = spec.cellWidth;
|
||||
r.height = spec.cellHeight;
|
||||
rects.push_back(r);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
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;
|
||||
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.
|
||||
std::string s;
|
||||
s.reserve(key.sampleId.size() + 32);
|
||||
s += std::to_string(key.sampleId.size());
|
||||
s += ':';
|
||||
s += key.sampleId;
|
||||
s += '|';
|
||||
s += std::to_string(key.width);
|
||||
s += '|';
|
||||
s += std::to_string(key.generation);
|
||||
return s;
|
||||
}
|
||||
|
||||
// --- Interaction --------------------------------------------------------------
|
||||
|
||||
int hitTestCell(int px, int py, const std::vector<CellRect>& 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<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool Selection::contains(int index) const {
|
||||
return std::binary_search(indices.begin(), indices.end(), index);
|
||||
}
|
||||
|
||||
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
|
||||
Selection s;
|
||||
s.indices = rangeIndices(anchor, index);
|
||||
s.focus = index;
|
||||
s.anchor = anchor; // anchor unchanged across a shift-range
|
||||
return s;
|
||||
}
|
||||
|
||||
if (ctrl) {
|
||||
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
|
||||
else
|
||||
s.indices.insert(it, index); // toggle IN (keeps sorted order)
|
||||
s.focus = index;
|
||||
s.anchor = index; // ctrl-click reseeds the range origin
|
||||
return s;
|
||||
}
|
||||
|
||||
// Plain click: sole selection.
|
||||
return singleSelection(index);
|
||||
}
|
||||
|
||||
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
|
||||
bool shift) {
|
||||
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.
|
||||
if (current.focus < 0 || current.focus >= itemCount) {
|
||||
if (shift) {
|
||||
Selection s;
|
||||
s.indices = {0};
|
||||
s.focus = 0;
|
||||
s.anchor = 0;
|
||||
return s;
|
||||
}
|
||||
return singleSelection(0);
|
||||
}
|
||||
|
||||
const int from = current.focus;
|
||||
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;
|
||||
else if (from + 1 < itemCount) // partial last row below us
|
||||
to = itemCount - 1;
|
||||
break;
|
||||
}
|
||||
case NavKey::Home: to = 0; break;
|
||||
case NavKey::End: to = itemCount - 1; break;
|
||||
}
|
||||
|
||||
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;
|
||||
Selection s;
|
||||
s.indices = rangeIndices(anchor, to);
|
||||
s.focus = to;
|
||||
s.anchor = anchor;
|
||||
return s;
|
||||
}
|
||||
|
||||
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.
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,178 @@
|
||||
#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 (bank_panel.cpp) 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.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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
|
||||
|
||||
// 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.
|
||||
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.
|
||||
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).
|
||||
std::vector<CellRect> 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).
|
||||
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.
|
||||
struct ThumbnailKey {
|
||||
std::string sampleId;
|
||||
int width = 0;
|
||||
std::uint64_t generation = 0;
|
||||
|
||||
bool operator==(const ThumbnailKey& o) const {
|
||||
return sampleId == o.sampleId && width == o.width &&
|
||||
generation == o.generation;
|
||||
}
|
||||
};
|
||||
|
||||
// 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).
|
||||
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 (bank_panel.cpp) reads live mouse
|
||||
// coordinates / key codes / modifier state via SWELL and calls into these; it owns
|
||||
// no selection arithmetic of its own.
|
||||
|
||||
// Hit-tests a point (SWELL/LICE top-left client coords) against a cell-rect list.
|
||||
// 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.
|
||||
int hitTestCell(int px, int py, const std::vector<CellRect>& rects);
|
||||
|
||||
// The panel's selection state. `indices` is the selected set as a SORTED, unique
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
struct Selection {
|
||||
std::vector<int> indices;
|
||||
int focus = -1;
|
||||
int anchor = -1;
|
||||
|
||||
bool operator==(const Selection& o) const {
|
||||
return indices == o.indices && focus == o.focus && anchor == o.anchor;
|
||||
}
|
||||
bool contains(int index) const;
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
// 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.
|
||||
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.
|
||||
float compressAmplitudeForDisplay(float linear);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,96 @@
|
||||
// card_drag — pure implementation. See card_drag.h. NO REAPER / SWELL / LICE / OS / vendor.
|
||||
|
||||
#include "core/ui/card_drag.h"
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
CursorCue cursorForGesture(CardGesture g) {
|
||||
switch (g) {
|
||||
case CardGesture::OsDragOut: return CursorCue::OsDragOut;
|
||||
case CardGesture::Move: return CursorCue::Move;
|
||||
case CardGesture::Copy: return CursorCue::Copy;
|
||||
case CardGesture::Reorder: return CursorCue::Reorder;
|
||||
case CardGesture::Replace: return CursorCue::Replace;
|
||||
case CardGesture::None: return CursorCue::Default;
|
||||
}
|
||||
return CursorCue::Default;
|
||||
}
|
||||
|
||||
std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
|
||||
const GridSpec& spec) {
|
||||
std::vector<SlotCellRect> rects;
|
||||
if (maxSlot < 0) return rects;
|
||||
|
||||
const int cols = columnsForWidth(panelWidth, spec);
|
||||
const int count = maxSlot + 1; // slots 0..maxSlot inclusive (empties included)
|
||||
rects.reserve(static_cast<std::size_t>(count));
|
||||
|
||||
for (int slot = 0; slot < count; ++slot) {
|
||||
const int col = slot % cols;
|
||||
const int row = slot / cols;
|
||||
SlotCellRect r;
|
||||
r.slot = slot;
|
||||
r.x = spec.gap + col * (spec.cellWidth + spec.gap);
|
||||
r.y = spec.gap + row * (spec.cellHeight + spec.gap);
|
||||
r.width = spec.cellWidth;
|
||||
r.height = spec.cellHeight;
|
||||
rects.push_back(r);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
std::vector<SlotCellRect> 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
|
||||
return computeSlotRects(newMax, panelWidth, spec);
|
||||
}
|
||||
|
||||
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& 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;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,147 @@
|
||||
#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 (bank_panel.cpp). Mirror of drag_out::decideGesture.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/ui/bank_grid.h" // CellRect
|
||||
#include "core/ui/drag_out.h" // PanelClientRect, DragState
|
||||
|
||||
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.)
|
||||
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)
|
||||
};
|
||||
|
||||
// The resolved gesture — one clean outcome 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
|
||||
};
|
||||
|
||||
// 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).
|
||||
struct DragModifiers {
|
||||
DropRegion region = DropRegion::DeadSpace;
|
||||
int targetSlot = -1;
|
||||
bool slotOccupied = false;
|
||||
bool ctrl = false;
|
||||
bool alt = false;
|
||||
};
|
||||
|
||||
// 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).
|
||||
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
|
||||
};
|
||||
|
||||
// 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).
|
||||
struct SlotCellRect {
|
||||
int slot = 0; // the model slot this rect represents (0..maxSlot)
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const SlotCellRect& o) const {
|
||||
return slot == o.slot && x == o.x && y == o.y &&
|
||||
width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
std::vector<SlotCellRect> 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).
|
||||
std::vector<SlotCellRect> 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.
|
||||
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,64 @@
|
||||
// card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor.
|
||||
|
||||
#include "core/ui/card_meta.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
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.
|
||||
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.
|
||||
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<long>(wholeBeats);
|
||||
const long bar = wb / m.timeSigNum + 1; // 1-based bar
|
||||
const long beat = wb % m.timeSigNum + 1; // 1-based beat within the bar
|
||||
|
||||
// Subdivision: hundredths of a beat, floored (0..99). A decorative display quantum.
|
||||
int sub = static_cast<int>(std::floor(frac * 100.0));
|
||||
if (sub < 0) sub = 0;
|
||||
if (sub > 99) sub = 99;
|
||||
|
||||
char buf[48];
|
||||
std::snprintf(buf, sizeof(buf), "%ld.%ld.%02d", bar, beat, sub);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string formatSecondsMs(double lengthSeconds) {
|
||||
double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0;
|
||||
|
||||
long secs = static_cast<long>(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.
|
||||
int ms = static_cast<int>((len - static_cast<double>(secs)) * 1000.0 + 0.5);
|
||||
// Rounding can push ms to 1000 at a whole-second boundary; carry into seconds.
|
||||
if (ms >= 1000) { ms -= 1000; ++secs; }
|
||||
if (ms < 0) ms = 0;
|
||||
|
||||
char buf[48];
|
||||
std::snprintf(buf, sizeof(buf), "%ld.%03d", secs, ms);
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,55 @@
|
||||
#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.
|
||||
|
||||
#include <string>
|
||||
|
||||
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.
|
||||
struct MusicalLength {
|
||||
double lengthSeconds = 0.0;
|
||||
double tempoBpm = 0.0;
|
||||
int timeSigNum = 0;
|
||||
int timeSigDenom = 0;
|
||||
};
|
||||
|
||||
// bars.beats.subdivisions from a capture-time tempo + meter stamp (musical read-out).
|
||||
//
|
||||
// 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).
|
||||
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).
|
||||
std::string formatSecondsMs(double lengthSeconds);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,108 @@
|
||||
// component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL /
|
||||
// LICE / vendor. Standard library only.
|
||||
|
||||
#include "core/ui/component_geometry.h"
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
bool hitTestBox(int px, int py, const KitBox& box) {
|
||||
if (box.empty()) return false;
|
||||
return px >= box.x && px < box.x + box.width &&
|
||||
py >= box.y && py < box.y + box.height;
|
||||
}
|
||||
|
||||
KitButtonBox computeButtonBox(const KitBox& cell, int padding) {
|
||||
if (cell.empty()) return {};
|
||||
if (padding < 0) padding = 0;
|
||||
KitBox b;
|
||||
b.x = cell.x + 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
|
||||
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;
|
||||
if (value > 1.0) value = 1.0;
|
||||
|
||||
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
|
||||
if (track.width < 0) track.width = 0;
|
||||
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<int>(value * track.width + 0.5);
|
||||
KitBox handle;
|
||||
handle.x = centre - half;
|
||||
handle.y = control.y + (control.height - handleSize) / 2;
|
||||
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;
|
||||
filled.width = centre - track.x;
|
||||
if (filled.width < 0) filled.width = 0;
|
||||
filled.height = track.height;
|
||||
|
||||
return SliderGeometry{track, filled, handle};
|
||||
}
|
||||
|
||||
double sliderValueAt(int px, const KitBox& control, int handleSize) {
|
||||
if (control.empty() || handleSize <= 0) return 0.0;
|
||||
if (control.width < handleSize) return 0.0;
|
||||
|
||||
const int half = handleSize / 2;
|
||||
const int trackStart = control.x + half;
|
||||
const int trackSpan = control.width - handleSize; // matches computeSlider's travel
|
||||
if (trackSpan <= 0) return 0.0;
|
||||
|
||||
if (px <= trackStart) return 0.0;
|
||||
if (px >= trackStart + trackSpan) return 1.0;
|
||||
return static_cast<double>(px - trackStart) / static_cast<double>(trackSpan);
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
return row;
|
||||
}
|
||||
|
||||
int waveformColumnCount(const KitBox& box) {
|
||||
const int w = box.width - 4; // fixed 2px inset each side (matches drawWaveform)
|
||||
return w > 0 ? w : 0;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,129 @@
|
||||
#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
|
||||
// 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
|
||||
|
||||
// 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).
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
struct ListRowBox {
|
||||
int index = 0; // the row's index in the caller's list (0-based, top-first)
|
||||
KitBox box;
|
||||
|
||||
bool operator==(const ListRowBox& o) const {
|
||||
return index == o.index && box == o.box;
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
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.
|
||||
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.
|
||||
int waveformColumnCount(const KitBox& box);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,54 @@
|
||||
// drag_out — pure implementation. See drag_out.h. NO REAPER / SWELL / OS / vendor.
|
||||
|
||||
#include "core/ui/drag_out.h"
|
||||
|
||||
#include <unordered_set>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
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.
|
||||
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
|
||||
return DragGesture::OsDrag;
|
||||
}
|
||||
|
||||
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
|
||||
PathList out;
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(resolved.size());
|
||||
|
||||
for (const ResolvedSample& s : resolved) {
|
||||
if (s.absolutePath.empty()) { // shell could not resolve it
|
||||
++out.skippedUnresolved;
|
||||
continue;
|
||||
}
|
||||
if (!s.fileExists) { // stale index entry, file gone
|
||||
++out.skippedMissing;
|
||||
continue;
|
||||
}
|
||||
if (!seen.insert(s.absolutePath).second) { // already emitted this path
|
||||
++out.skippedDuplicate;
|
||||
continue;
|
||||
}
|
||||
out.paths.push_back(s.absolutePath);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,132 @@
|
||||
#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.* + bank_panel.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).
|
||||
//
|
||||
// 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.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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 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.
|
||||
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)
|
||||
};
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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.
|
||||
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.
|
||||
struct ResolvedSample {
|
||||
std::string absolutePath; // resolved absolute path, or "" when unresolvable
|
||||
bool fileExists = false; // shell stat() result — drives the skip-missing policy
|
||||
};
|
||||
|
||||
// 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).
|
||||
struct PathList {
|
||||
std::vector<std::string> 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
|
||||
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).
|
||||
PathList assemblePathList(const std::vector<ResolvedSample>& resolved);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,69 @@
|
||||
// footer_bar — pure implementation. See footer_bar.h. NO REAPER / SWELL / LICE / vendor.
|
||||
|
||||
#include "core/ui/footer_bar.h"
|
||||
|
||||
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).
|
||||
bool fitsLeftOf(int x, int width, int rightBound) {
|
||||
return width > 0 && x + width <= rightBound;
|
||||
}
|
||||
|
||||
bool pointIn(int px, int py, const FooterBarRect& r) {
|
||||
return !r.empty() && px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec) {
|
||||
FooterBarLayout out;
|
||||
if (footer.width <= 0 || footer.height <= 0) return out;
|
||||
|
||||
const int top = footer.y + spec.verticalInset;
|
||||
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.
|
||||
int rightBound = footer.x + footer.width - spec.rightReserve;
|
||||
if (rightBound < footer.x) rightBound = footer.x;
|
||||
|
||||
int cursorX = footer.x + spec.leftPad;
|
||||
|
||||
// Toggle (most important — placed first, drops last).
|
||||
if (fitsLeftOf(cursorX, spec.toggleWidth, rightBound)) {
|
||||
out.toggle = FooterBarRect{cursorX, top, spec.toggleWidth, boxH};
|
||||
cursorX += spec.toggleWidth + spec.gap;
|
||||
} else {
|
||||
return out; // no room for even the toggle — nothing else can fit either
|
||||
}
|
||||
|
||||
// Count label (passive readout). Suppressed by countWidth <= 0 (no gap consumed then).
|
||||
if (spec.countWidth > 0) {
|
||||
if (fitsLeftOf(cursorX, spec.countWidth, rightBound)) {
|
||||
out.count = FooterBarRect{cursorX, top, spec.countWidth, boxH};
|
||||
cursorX += spec.countWidth + spec.gap;
|
||||
}
|
||||
// If the count does not fit, do NOT advance the cursor past it — the Tail button then
|
||||
// gets its chance at the same slot (a passive label yields to the interactive button).
|
||||
}
|
||||
|
||||
// Tail button.
|
||||
if (fitsLeftOf(cursorX, spec.tailWidth, rightBound))
|
||||
out.tail = FooterBarRect{cursorX, top, spec.tailWidth, boxH};
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) {
|
||||
// Toggle first (matches the shell's segment sub-hit precedence), then the Tail button. The
|
||||
// count label is a passive readout — never a hit target.
|
||||
if (pointIn(px, py, layout.toggle)) return FooterHit::Toggle;
|
||||
if (pointIn(px, py, layout.tail)) return FooterHit::Tail;
|
||||
return FooterHit::None;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,106 @@
|
||||
#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
|
||||
// (bank_panel.cpp) 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) -------------------------------
|
||||
//
|
||||
// [Arrange|Design] toggle . count label . Tail button . ... . Prune (rightmost, warn)
|
||||
//
|
||||
// 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.
|
||||
|
||||
#include "core/ui/prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted)
|
||||
|
||||
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
|
||||
|
||||
// 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).
|
||||
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)
|
||||
|
||||
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.
|
||||
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.
|
||||
struct FooterBarSpec {
|
||||
int toggleWidth = 132;
|
||||
int countWidth = 64;
|
||||
int tailWidth = 132;
|
||||
int gap = 6;
|
||||
int leftPad = 8;
|
||||
int verticalInset = 4;
|
||||
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).
|
||||
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.
|
||||
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,21 @@
|
||||
// mode_enable — pure implementation. See mode_enable.h. NO REAPER / SWELL / LICE / vendor.
|
||||
|
||||
#include "core/ui/mode_enable.h"
|
||||
|
||||
#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids
|
||||
|
||||
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.
|
||||
if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true;
|
||||
|
||||
return activeModeId != targetId;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,39 @@
|
||||
#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.
|
||||
|
||||
#include <string>
|
||||
|
||||
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.
|
||||
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.
|
||||
bool tagButtonEnabled(const std::string& activeModeId, TagTarget target);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,42 @@
|
||||
// overflow_menu — pure implementation. See overflow_menu.h. NO REAPER / SWELL / LICE / vendor.
|
||||
|
||||
#include "core/ui/overflow_menu.h"
|
||||
|
||||
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.
|
||||
return spec.buttonWidth + 2 * spec.rightInset;
|
||||
}
|
||||
|
||||
MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec) {
|
||||
MenuButtonRect btn;
|
||||
if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return btn;
|
||||
|
||||
const int right = bar.x + bar.width - spec.rightInset;
|
||||
const int left = right - spec.buttonWidth;
|
||||
if (left < bar.x + spec.minLeftInset) return btn; // too narrow — suppress
|
||||
|
||||
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
|
||||
top = bar.y;
|
||||
height = bar.height;
|
||||
}
|
||||
|
||||
btn.x = left;
|
||||
btn.y = top;
|
||||
btn.width = spec.buttonWidth;
|
||||
btn.height = height;
|
||||
return btn;
|
||||
}
|
||||
|
||||
bool hitTestMenuButton(int px, int py, const MenuButtonRect& button) {
|
||||
if (button.empty()) return false;
|
||||
return px >= button.x && px < button.x + button.width &&
|
||||
py >= button.y && py < button.y + button.height;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,68 @@
|
||||
#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.
|
||||
|
||||
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 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
|
||||
|
||||
// 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).
|
||||
struct MenuButtonSpec {
|
||||
int buttonWidth = 28;
|
||||
int rightInset = 6;
|
||||
int verticalInset = 3;
|
||||
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).
|
||||
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).
|
||||
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).
|
||||
bool hitTestMenuButton(int px, int py, const MenuButtonRect& button);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,39 @@
|
||||
#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.
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec) {
|
||||
if (footer.width <= 0 || footer.height <= 0) return ButtonRect{}; // degenerate footer
|
||||
if (spec.buttonWidth <= 0) return ButtonRect{}; // nothing to place
|
||||
|
||||
// Right-anchored: right edge inset from the footer's right; width fixed.
|
||||
const int right = footer.x + footer.width - spec.rightInset;
|
||||
const int left = right - spec.buttonWidth;
|
||||
|
||||
// Suppress if the button would encroach past the reserved left inset (tail label room)
|
||||
// or spill off the left of the footer entirely.
|
||||
if (left < footer.x + spec.minLeftInset) return ButtonRect{};
|
||||
|
||||
// Vertically centred by the inset; clamp so a thin footer never yields a negative height.
|
||||
int top = footer.y + spec.verticalInset;
|
||||
int height = footer.height - 2 * spec.verticalInset;
|
||||
if (height <= 0) {
|
||||
top = footer.y;
|
||||
height = footer.height;
|
||||
}
|
||||
|
||||
return ButtonRect{left, top, spec.buttonWidth, height};
|
||||
}
|
||||
|
||||
bool hitTestPruneButton(int px, int py, const ButtonRect& button) {
|
||||
if (button.empty()) return false; // suppressed button claims nothing
|
||||
return px >= button.x && px < button.x + button.width &&
|
||||
py >= button.y && py < button.y + button.height;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,82 @@
|
||||
#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
|
||||
// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the Main_OnCommand
|
||||
// dispatch of the registered command id — all REAPER-bound, DAW-verified. What is
|
||||
// NOT DAW-bound — WHERE the button sits in the footer and whether a click lands on
|
||||
// it — lives here so it is unit-tested outside the DAW (CLAUDE.md §load-bearing
|
||||
// split). Mirror of mode_switch / tab_strip.
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// 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.
|
||||
|
||||
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
|
||||
|
||||
// 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 (bank_panel.cpp): the version readout
|
||||
// uses an 8 px right margin. The button's right edge lands at
|
||||
// footer.right - 84, i.e. 76 px left of the readout's right margin —
|
||||
// enough clearance for the ~10-char label. ALSO COUPLED to
|
||||
// FooterBarSpec::rightReserve (footer_bar.h): the L4 footer-left group
|
||||
// (mode toggle + count + Tail) reserves that many px at the right so it
|
||||
// never runs under this button; rightReserve must exceed rightInset +
|
||||
// buttonWidth. If the version readout's inset changes in drawFooter,
|
||||
// update this value to maintain clearance.
|
||||
// * verticalInset — top/bottom gap inside the footer (the button is shorter than the
|
||||
// strip so it reads as a raised control, not a full-height fill).
|
||||
// * minLeftInset — the button's left edge must stay at least this far from the footer
|
||||
// left edge (reserving room for the L4 footer-left group). If the button
|
||||
// would encroach past this, computePruneButton yields an empty rect
|
||||
// (button suppressed — see header placement contract).
|
||||
struct PruneButtonSpec {
|
||||
int buttonWidth = 72;
|
||||
int rightInset = 84; // COUPLED: version readout in drawFooter uses an 8 px right margin
|
||||
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.
|
||||
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.
|
||||
bool hitTestPruneButton(int px, int py, const ButtonRect& button);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,56 @@
|
||||
#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.
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
struct Rect {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
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).
|
||||
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().
|
||||
static Rect ltrb(int left, int top, int right, int bottom) {
|
||||
return Rect{left, top, right - left, bottom - top};
|
||||
}
|
||||
|
||||
bool operator==(const Rect& o) const {
|
||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
||||
}
|
||||
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.
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,112 @@
|
||||
// tab_strip — pure implementation. See tab_strip.h. NO REAPER / SWELL / vendor.
|
||||
|
||||
#include "core/ui/tab_strip.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
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
|
||||
TabStripLayout out;
|
||||
if (tabCount <= 0 || strip.width <= 0) {
|
||||
out.trackX = strip.x;
|
||||
out.trackWidth = strip.width > 0 ? strip.width : 0;
|
||||
return out; // nothing to lay out: track == strip, no overflow, no chevrons
|
||||
}
|
||||
|
||||
const int totalTabsWidth = tabCount * spec.tabWidth;
|
||||
if (totalTabsWidth <= strip.width) {
|
||||
// Everything fits: the whole strip is the track; no chevrons, no scroll.
|
||||
out.overflow = false;
|
||||
out.trackX = strip.x;
|
||||
out.trackWidth = strip.width;
|
||||
out.maxScroll = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Overflow: reserve a chevron band at each end; the tabs live between them.
|
||||
out.overflow = true;
|
||||
out.leftChevron = true;
|
||||
out.rightChevron = true;
|
||||
out.trackX = strip.x + spec.chevronWidth;
|
||||
out.trackWidth = strip.width - 2 * spec.chevronWidth;
|
||||
if (out.trackWidth < 0) out.trackWidth = 0;
|
||||
// The tab run exceeds the track by this many pixels; the strip may scroll exactly
|
||||
// that far so the last tab's right edge reaches the track's right edge, no more.
|
||||
out.maxScroll = totalTabsWidth - out.trackWidth;
|
||||
if (out.maxScroll < 0) out.maxScroll = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
int clampTabScroll(int desiredOffset, const TabStripLayout& layout) {
|
||||
if (desiredOffset < 0) return 0;
|
||||
if (desiredOffset > layout.maxScroll) return layout.maxScroll;
|
||||
return desiredOffset;
|
||||
}
|
||||
|
||||
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset) {
|
||||
std::vector<TabRect> rects;
|
||||
if (tabCount <= 0 || strip.width <= 0) return rects;
|
||||
|
||||
const TabStripLayout layout =
|
||||
computeTabStripLayout(strip, tabCount, spec, scrollOffset);
|
||||
const int offset = layout.overflow ? clampTabScroll(scrollOffset, layout) : 0;
|
||||
const int trackLeft = layout.trackX;
|
||||
const int trackRight = layout.trackX + layout.trackWidth;
|
||||
|
||||
rects.reserve(static_cast<std::size_t>(tabCount));
|
||||
for (int i = 0; i < tabCount; ++i) {
|
||||
const int rawLeft = trackLeft + i * spec.tabWidth - offset;
|
||||
const int rawRight = rawLeft + spec.tabWidth;
|
||||
// Clip to the track: a partially-scrolled tab must not draw under a chevron
|
||||
// or spill past the track. A tab whose clipped extent is empty is omitted.
|
||||
int left = rawLeft < trackLeft ? trackLeft : rawLeft;
|
||||
int right = rawRight > trackRight ? trackRight : rawRight;
|
||||
if (right <= left) continue; // fully scrolled out of view either side
|
||||
TabRect r;
|
||||
r.index = i;
|
||||
r.x = left;
|
||||
r.y = strip.y;
|
||||
r.width = right - left;
|
||||
r.height = strip.height;
|
||||
rects.push_back(r);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset) {
|
||||
TabHit miss; // {None, -1}
|
||||
if (tabCount <= 0 || strip.width <= 0 || strip.height <= 0) return miss;
|
||||
|
||||
// Reject anything outside the strip band first (half-open bounds).
|
||||
if (px < strip.x || px >= strip.x + strip.width ||
|
||||
py < strip.y || py >= strip.y + strip.height)
|
||||
return miss;
|
||||
|
||||
const TabStripLayout layout =
|
||||
computeTabStripLayout(strip, tabCount, spec, scrollOffset);
|
||||
|
||||
// Chevrons take precedence at the strip ends: a click in a reserved chevron band
|
||||
// is a scroll, never a tab (the tab track excludes those bands).
|
||||
if (layout.overflow) {
|
||||
if (px < strip.x + spec.chevronWidth)
|
||||
return TabHit{TabHitKind::ScrollLeft, -1};
|
||||
if (px >= strip.x + strip.width - spec.chevronWidth)
|
||||
return TabHit{TabHitKind::ScrollRight, -1};
|
||||
}
|
||||
|
||||
// Inside the track: find the visible tab whose clipped rect contains px. Reuse
|
||||
// computeTabRects so the hit matches exactly what was drawn (clipping included).
|
||||
const std::vector<TabRect> rects =
|
||||
computeTabRects(strip, tabCount, spec, scrollOffset);
|
||||
for (const TabRect& r : rects) {
|
||||
if (px >= r.x && px < r.x + r.width) return TabHit{TabHitKind::Tab, r.index};
|
||||
}
|
||||
return miss; // track dead space (no tab under the point)
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,127 @@
|
||||
#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 (bank_panel.cpp) owns the
|
||||
// SWELL window, LICE drawing, and the live BankBook read; it calls into this seam
|
||||
// for every rect and every hit. Mirror of mode_switch / bank_grid.
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only. Builds and unit-tests without REAPER.
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::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
|
||||
|
||||
// Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed
|
||||
// so the strip reads as a uniform segmented control and overflow math stays simple —
|
||||
// labels ellipsize within the tab, they do not resize it). chevronWidth is the width
|
||||
// reserved at each end for the scroll affordance WHEN the tabs overflow; when they
|
||||
// fit, no chevron is reserved and the tabs use the full strip width.
|
||||
struct TabStripSpec {
|
||||
int tabWidth = 96;
|
||||
int chevronWidth = 20;
|
||||
};
|
||||
|
||||
// One tab's pixel rectangle within the strip, top-left origin, ALREADY translated
|
||||
// by the current scroll offset and clipped to the visible track. `index` is the
|
||||
// tab's index in the caller's list (ordinal order) so the shell can label/light it
|
||||
// without re-deriving. A tab scrolled fully out of view is omitted from the result
|
||||
// (the shell only draws what computeTabRects returns), so every returned rect is at
|
||||
// least partially visible.
|
||||
struct TabRect {
|
||||
int index = 0;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const TabRect& o) const {
|
||||
return index == o.index && x == o.x && y == o.y &&
|
||||
width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// The scrollable track's geometry: where the tabs may be drawn (between the
|
||||
// chevrons when overflowing, or the whole strip when they fit) and whether each
|
||||
// chevron is present. Derived once and shared by layout + hit-testing so both agree.
|
||||
struct TabStripLayout {
|
||||
bool overflow = false; // true iff N tabs at tabWidth exceed the track width
|
||||
int trackX = 0; // left edge of the tab track (past the left chevron)
|
||||
int trackWidth = 0; // width available to tabs (strip minus both chevrons)
|
||||
int maxScroll = 0; // largest valid scroll offset (0 when no overflow)
|
||||
bool leftChevron = false; // a left-scroll affordance is reserved this frame
|
||||
bool rightChevron = false;// a right-scroll affordance is reserved this frame
|
||||
};
|
||||
|
||||
// Computes the strip layout for `tabCount` tabs of `spec.tabWidth` in `strip`,
|
||||
// given the current `scrollOffset`. Pure geometry:
|
||||
// * No overflow (all tabs fit the strip width): overflow=false, no chevrons, the
|
||||
// track IS the strip, maxScroll=0.
|
||||
// * Overflow: both chevrons are reserved (chevronWidth each), the track is the
|
||||
// strip minus both chevrons, and maxScroll is the pixels by which the tab run
|
||||
// exceeds the track (so the last tab's right edge can reach the track's right
|
||||
// edge but not scroll past it). Chevrons are always both present under overflow
|
||||
// (a fixed affordance is simpler and unambiguous than hiding one at an end;
|
||||
// clicking a chevron at a scroll limit is a harmless no-op the shell clamps).
|
||||
// tabCount <= 0 or a non-positive strip width returns a zeroed layout (no overflow,
|
||||
// track == strip, maxScroll 0).
|
||||
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
// Clamps a desired scroll offset into [0, maxScroll] for the given layout. The shell
|
||||
// calls this after a chevron click / wheel so the strip never scrolls past either
|
||||
// end. maxScroll is 0 when the tabs fit, so a fitting strip always clamps to 0.
|
||||
int clampTabScroll(int desiredOffset, const TabStripLayout& layout);
|
||||
|
||||
// Tiles `tabCount` fixed-width tabs left-to-right into the layout's track, shifted
|
||||
// left by `scrollOffset`, and returns the rects that are at least partially visible
|
||||
// (in tab-index order). Each tab i sits at trackX + i*tabWidth - scrollOffset; a tab
|
||||
// whose visible extent is empty (fully left of or right of the track) is omitted.
|
||||
// Returned rects are CLIPPED to the track horizontally so a partially-scrolled tab
|
||||
// does not draw under a chevron. The caller passes the SAME scrollOffset it passed
|
||||
// to computeTabStripLayout (the shell clamps once, then uses the clamped value for
|
||||
// both). tabCount <= 0 -> empty.
|
||||
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
// What a point in the strip resolves to.
|
||||
enum class TabHitKind {
|
||||
None, // outside the strip, or in dead space between visible tabs
|
||||
Tab, // a tab — `index` is the tab's index in the caller's list
|
||||
ScrollLeft, // the left overflow chevron
|
||||
ScrollRight, // the right overflow chevron
|
||||
};
|
||||
|
||||
// The outcome of hit-testing a point against the strip. For Tab, `index` is the tab
|
||||
// index; for the chevrons and None it is -1.
|
||||
struct TabHit {
|
||||
TabHitKind kind = TabHitKind::None;
|
||||
int index = -1;
|
||||
|
||||
bool operator==(const TabHit& o) const {
|
||||
return kind == o.kind && index == o.index;
|
||||
}
|
||||
};
|
||||
|
||||
// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out
|
||||
// for `tabCount` tabs at `scrollOffset`. Chevrons take precedence over tabs at the
|
||||
// strip ends (a click in the reserved chevron band is a scroll, never a tab), and a
|
||||
// point outside the strip band, or in the track but not on any visible tab, is None.
|
||||
// Half-open bounds match computeTabRects / the chevron bands so no pixel is claimed
|
||||
// twice. The shell passes the SAME clamped scrollOffset it drew with.
|
||||
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,193 @@
|
||||
// theme — pure implementation. See theme.h. NO REAPER / SWELL / LICE / vendor.
|
||||
|
||||
#include "core/ui/theme.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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).
|
||||
// ===========================================================================
|
||||
|
||||
// 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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
// --- state transform helpers -------------------------------------------------
|
||||
|
||||
std::uint8_t clamp8(int v) {
|
||||
return static_cast<std::uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v));
|
||||
}
|
||||
|
||||
// Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state
|
||||
// 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<int>(std::lround(a.r + (b.r - a.r) * t))),
|
||||
clamp8(static_cast<int>(std::lround(a.g + (b.g - a.g) * t))),
|
||||
clamp8(static_cast<int>(std::lround(a.b + (b.b - a.b) * t))),
|
||||
a.a,
|
||||
};
|
||||
}
|
||||
|
||||
// Scale RGB by factor (brightness up/down), alpha untouched.
|
||||
KitColor scale(const KitColor& c, double factor) {
|
||||
return KitColor{
|
||||
clamp8(static_cast<int>(std::lround(c.r * factor))),
|
||||
clamp8(static_cast<int>(std::lround(c.g * factor))),
|
||||
clamp8(static_cast<int>(std::lround(c.b * factor))),
|
||||
c.a,
|
||||
};
|
||||
}
|
||||
|
||||
// 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<int>(
|
||||
std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b)));
|
||||
const KitColor g{static_cast<std::uint8_t>(gray),
|
||||
static_cast<std::uint8_t>(gray),
|
||||
static_cast<std::uint8_t>(gray), c.a};
|
||||
return mix(c, g, amount);
|
||||
}
|
||||
|
||||
double linearizeChannel(std::uint8_t v) {
|
||||
const double s = v / 255.0;
|
||||
return s <= 0.03928 ? s / 12.92 : std::pow((s + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
KitColor roleColor(Role role) {
|
||||
switch (role) {
|
||||
case Role::BgBase: return kDirBgBase;
|
||||
case Role::BgPanel: return kDirBgPanel;
|
||||
case Role::BgCell: return kDirBgCell;
|
||||
case Role::LineHairline: return kDirHairline;
|
||||
case Role::TextPrimary: return kDirTextPrimary;
|
||||
case Role::TextDim: return kDirTextDim;
|
||||
case Role::AccentPrimary: return kDirAccentPrimary;
|
||||
case Role::AccentSecondary: return kDirAccentSecondary;
|
||||
case Role::AccentTertiary: return kDirAccentTertiary;
|
||||
case Role::AccentHot: return kDirAccentHot;
|
||||
case Role::Warn: return kDirWarn;
|
||||
}
|
||||
return kDirBgBase; // unreachable; keeps non-void control flow total
|
||||
}
|
||||
|
||||
KitColor roleColorState(Role role, InteractionState state) {
|
||||
const KitColor base = roleColor(role);
|
||||
switch (state) {
|
||||
case InteractionState::Rest:
|
||||
return base;
|
||||
case InteractionState::Hover:
|
||||
// Lighten the surface 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).
|
||||
return roleColor(Role::AccentPrimary);
|
||||
case InteractionState::Pressed:
|
||||
// The surface "pushes in": darken.
|
||||
return scale(base, 0.82);
|
||||
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.
|
||||
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::uint8_t>(std::lround(base.a * 0.4));
|
||||
return d;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
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.
|
||||
if (t <= 0.5) {
|
||||
return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5);
|
||||
}
|
||||
return mix(kDirSpectralMid, kDirSpectralHi, (t - 0.5) / 0.5);
|
||||
}
|
||||
|
||||
double relativeLuminance(const KitColor& c) {
|
||||
return 0.2126 * linearizeChannel(c.r) +
|
||||
0.7152 * linearizeChannel(c.g) +
|
||||
0.0722 * linearizeChannel(c.b);
|
||||
}
|
||||
|
||||
double contrastRatio(const KitColor& a, const KitColor& b) {
|
||||
const double la = relativeLuminance(a);
|
||||
const double lb = relativeLuminance(b);
|
||||
const double lighter = std::max(la, lb);
|
||||
const double darker = std::min(la, lb);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
double textFloor(TextClass cls) {
|
||||
return cls == TextClass::Body ? 4.5 : 3.0;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,118 @@
|
||||
#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").
|
||||
//
|
||||
// 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.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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.
|
||||
struct KitColor {
|
||||
std::uint8_t r = 0;
|
||||
std::uint8_t g = 0;
|
||||
std::uint8_t b = 0;
|
||||
std::uint8_t a = 255;
|
||||
|
||||
bool operator==(const KitColor& o) const {
|
||||
return r == o.r && g == o.g && b == o.b && a == o.a;
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
enum class Role {
|
||||
BgBase, // window canvas
|
||||
BgPanel, // a raised region (list, waveform pane)
|
||||
BgCell, // a control / row surface
|
||||
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)
|
||||
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.
|
||||
enum class InteractionState {
|
||||
Rest,
|
||||
Hover,
|
||||
Active, // selected / 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.
|
||||
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.
|
||||
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.
|
||||
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].
|
||||
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.
|
||||
double relativeLuminance(const KitColor& c);
|
||||
|
||||
// The WCAG contrast ratio between two colors, in [1, 21]. Symmetric; order-independent.
|
||||
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.
|
||||
double textFloor(TextClass cls);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,53 @@
|
||||
// tooltip — pure implementation. See tooltip.h. NO REAPER / SWELL / LICE / vendor.
|
||||
|
||||
#include "core/ui/tooltip.h"
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
std::string stripActionPrefix(const std::string& fullName, const std::string& prefix) {
|
||||
if (prefix.empty()) return fullName;
|
||||
if (fullName.size() >= prefix.size() &&
|
||||
fullName.compare(0, prefix.size(), prefix) == 0)
|
||||
return fullName.substr(prefix.size());
|
||||
return fullName;
|
||||
}
|
||||
|
||||
TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH,
|
||||
int textW, int textH, int clientW, int clientH,
|
||||
const TooltipSpec& spec) {
|
||||
TooltipBox box;
|
||||
if (textW <= 0 || textH <= 0 || clientW <= 0 || clientH <= 0) return box;
|
||||
|
||||
// Clamp boxW so it never exceeds the available client span; then clamp x so the (possibly
|
||||
// reduced) box always sits within [margin, clientW - margin].
|
||||
const int maxBoxW = clientW - 2 * spec.margin;
|
||||
const int boxW = (textW + 2 * spec.padX < maxBoxW) ? textW + 2 * spec.padX : maxBoxW;
|
||||
const int boxH = textH + 2 * spec.padY;
|
||||
|
||||
// Horizontal: centre on the anchor, then clamp within [margin, clientW - margin - boxW].
|
||||
int x = anchorX + (anchorW - boxW) / 2;
|
||||
const int maxX = clientW - spec.margin - boxW;
|
||||
if (x > maxX) x = maxX;
|
||||
if (x < spec.margin) x = spec.margin;
|
||||
|
||||
// Vertical: prefer BELOW the anchor; flip ABOVE if it would clip the bottom edge.
|
||||
int y = anchorY + anchorH + spec.gap;
|
||||
if (y + boxH > clientH - spec.margin) {
|
||||
const int above = anchorY - spec.gap - boxH;
|
||||
if (above >= spec.margin) {
|
||||
y = above; // fits above — flip
|
||||
} else {
|
||||
// Fits neither cleanly (tall tooltip / short client): clamp to the bottom margin.
|
||||
const int maxY = clientH - spec.margin - boxH;
|
||||
y = maxY < spec.margin ? spec.margin : maxY;
|
||||
}
|
||||
}
|
||||
|
||||
box.x = x;
|
||||
box.y = y;
|
||||
box.width = boxW;
|
||||
box.height = boxH;
|
||||
return box;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,62 @@
|
||||
#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.
|
||||
|
||||
#include <string>
|
||||
|
||||
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.
|
||||
struct TooltipBox {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool empty() const { return width <= 0 || height <= 0; }
|
||||
|
||||
bool operator==(const TooltipBox& o) const {
|
||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
struct TooltipSpec {
|
||||
int gap = 4;
|
||||
int padX = 6;
|
||||
int padY = 3;
|
||||
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.
|
||||
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).
|
||||
TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH,
|
||||
int textW, int textH, int clientW, int clientH,
|
||||
const TooltipSpec& spec);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
Reference in New Issue
Block a user