L1: shared LICE drawing kit — theme/palette + component geometry + draw kit; retire GDI text in bank_panel

This commit is contained in:
2026-07-26 20:04:04 -04:00
parent ec05af35ef
commit beca6487c7
10 changed files with 1394 additions and 42 deletions
+46 -36
View File
@@ -55,6 +55,8 @@
#include "bank_grid.h"
#include "bank_model.h"
#include "capture_paths.h"
#include "component_geometry.h" // KitBox — the kit text()'s draw box (L1)
#include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1)
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
@@ -482,15 +484,38 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
}
}
// Draws a centered single-line label into a rect (COLORREF text).
void drawCenteredText(LICE_IBitmap* bmp, const RECT& rc, const char* text,
// --- GDI-text retirement (Phase L, L1) ----------------------------------------
//
// All panel text now draws through the kit's cached AA font (draw_kit::text), NOT raw GDI
// DrawText — the single biggest "temple os -> modern" lever. These thin adapters bridge the
// panel's existing RECT + COLORREF + DT_* call sites to the kit's KitBox + KitColor + Align
// so the retirement is mechanical and preserves each site's current color/alignment (L1 is
// the text-engine swap; the palette re-role is L2). The kit owns the font lifecycle
// (kitFontsInit/Shutdown, wired at panel open/close below).
KitBox toKitBox(const RECT& r) {
return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top};
}
KitColor toKitColor(COLORREF c) {
return KitColor{static_cast<std::uint8_t>(GetRValue(c)),
static_cast<std::uint8_t>(GetGValue(c)),
static_cast<std::uint8_t>(GetBValue(c)), 255};
}
// Maps the panel's DT_* horizontal flag to the kit's Align (the only three the panel uses).
Align toKitAlign(UINT fmt) {
if (fmt & DT_CENTER) return Align::Center;
if (fmt & DT_RIGHT) return Align::Right;
return Align::Left;
}
// Draws a single-line label into a rect through the kit's cached AA font (was GDI DrawText).
// `fmt` carries only the horizontal alignment (the kit always v-centers + single-lines +
// end-ellipsis, matching the retired DrawText flags). Font::Label is the panel's body face.
void drawCenteredText(LICE_IBitmap* bmp, const RECT& rc, const char* txt,
COLORREF color, UINT fmt) {
HDC dc = bmp->getDC();
if (!dc) return;
RECT r = rc;
SetTextColor(dc, color);
SetBkMode(dc, TRANSPARENT);
DrawText(dc, text, -1, &r, fmt | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
text(bmp, toKitBox(rc), txt, Font::Label, toKitColor(color), toKitAlign(fmt));
}
// --- Mode-switch header (D5, unchanged) ---------------------------------------
@@ -516,7 +541,6 @@ void drawModeSwitch(LICE_IBitmap* bmp, int w) {
if (segs.empty()) return;
const std::string& activeId = view.activeModeId();
HDC dc = bmp->getDC();
for (int i = 0; i < n; ++i) {
const SegmentRect& s = segs[static_cast<std::size_t>(i)];
@@ -527,12 +551,9 @@ void drawModeSwitch(LICE_IBitmap* bmp, int w) {
active ? kColSegActiveBg : kColSegBg, 1.0f, 0);
LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, kColSegBorder, 1.0f, 0);
if (!dc) continue;
RECT rc{s.x, s.y, s.x + s.width, s.y + s.height};
SetTextColor(dc, active ? kRgbSegActiveText : kRgbSegText);
SetBkMode(dc, TRANSPARENT);
DrawText(dc, mode.displayName.c_str(), -1, &rc,
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
drawCenteredText(bmp, rc, mode.displayName.c_str(),
active ? kRgbSegActiveText : kRgbSegText, DT_CENTER);
}
}
@@ -564,17 +585,11 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
LICE_FillRect(bmp, f.left, f.top, w, kFooterHeight, kColFooterBg, 1.0f, 0);
LICE_Line(bmp, f.left, f.top, f.right, f.top, kColFooterBorder, 1.0f, 0, false);
HDC dc = bmp->getDC();
if (!dc) return;
SetBkMode(dc, TRANSPARENT);
// Tail-mode toggle, left-aligned (the interactive control — footer clicks cycle it).
const std::string label = tailToggleLabel(currentTail());
RECT rc = f;
rc.left += 8;
SetTextColor(dc, kRgbFooterText);
DrawText(dc, label.c_str(), -1, &rc,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
drawCenteredText(bmp, rc, label.c_str(), kRgbFooterText, DT_LEFT);
// Version/channel readout (Phase V, V3/V4), right-aligned in the same footer strip so
// it is always visible but unobtrusive. appVersion() renders "0.9.01" on stable and
@@ -587,9 +602,8 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
// readout's right margin. If this inset (currently 8) changes, update rightInset there.
RECT vrc = f;
vrc.right -= 8; // COUPLED: PruneButtonSpec::rightInset in prune_button.h is 84
SetTextColor(dc, kRgbFooterVersion);
DrawText(dc, reasampler::appVersion().c_str(), -1, &vrc,
DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
drawCenteredText(bmp, vrc, reasampler::appVersion().c_str(),
kRgbFooterVersion, DT_RIGHT);
}
// The prune button's rect within the footer, derived from the client size. SINGLE source
@@ -615,13 +629,8 @@ void drawPruneButton(LICE_IBitmap* bmp, int w, int h) {
LICE_FillRect(bmp, b.x, b.y, b.width, b.height, kColPruneBtnBg, 1.0f, 0);
LICE_DrawRect(bmp, b.x, b.y, b.width, b.height, kColPruneBtnBorder, 1.0f, 0);
HDC dc = bmp->getDC();
if (!dc) return;
RECT rc{b.x, b.y, b.x + b.width, b.y + b.height};
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, kRgbPruneBtnText);
DrawText(dc, "Prune", -1, &rc,
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
drawCenteredText(bmp, rc, "Prune", kRgbPruneBtnText, DT_CENTER);
}
// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip.
@@ -745,21 +754,16 @@ void drawActionButtons(LICE_IBitmap* bmp, int w, int h) {
const std::vector<ActionButtonRect> rects =
computeButtonRects(strip, n, kButtonMinWidth);
HDC dc = bmp->getDC();
for (const ActionButtonRect& r : rects) {
LICE_FillRect(bmp, r.x + 1, r.y + 2, r.width - 2, r.height - 4,
kColActionBtnBg, 1.0f, 0);
LICE_DrawRect(bmp, r.x + 1, r.y + 2, r.width - 2, r.height - 4,
kColActionBtnBorder, 1.0f, 0);
if (!dc) continue;
const ActionButtonRow& row = rows[static_cast<std::size_t>(r.index)];
const int cmd = resolveActionCommandId(row);
const std::string label = actionButtonLabel(row, cmd);
RECT rc{r.x + 4, r.y, r.x + r.width - 4, r.y + r.height};
SetTextColor(dc, kRgbActionBtnText);
SetBkMode(dc, TRANSPARENT);
DrawText(dc, label.c_str(), -1, &rc,
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
drawCenteredText(bmp, rc, label.c_str(), kRgbActionBtnText, DT_CENTER);
}
}
@@ -2278,6 +2282,11 @@ void openPanel() {
}
initPreview();
// Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so
// a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts
// are torn down once at bankPanelShutdown. All panel text draws through these.
kitFontsInit();
g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL),
GetMainHwnd(), dlgProc, 0);
if (!g_panel.hwnd) return;
@@ -2407,6 +2416,7 @@ void bankPanelToggledBanksFullHeight() {
void bankPanelShutdown() {
closePanel();
deinitPreview();
kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1)
g_panel.cache.clear();
g_panel.session = nullptr;
}
+103
View File
@@ -0,0 +1,103 @@
// component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL /
// LICE / vendor. Standard library only.
#include "component_geometry.h"
namespace reasampler {
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;
}
} // namespace reasampler
+129
View File
@@ -0,0 +1,129 @@
#pragma once
// 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 / action_buttons / 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 {
// 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.
struct KitBox {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool empty() const { return width <= 0 || height <= 0; }
bool operator==(const KitBox& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
};
// 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/action_buttons, which own their
// OWN placement within their strips — 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);
} // namespace reasampler
+323
View File
@@ -0,0 +1,323 @@
// draw_kit — the LICE/SWELL shell half of the drawing kit. See draw_kit.h.
//
// Compiled into the reaper_reasampler MODULE. SHELL layer: it is the only kit file that
// touches LICE + SWELL. All colors come from the pure `theme` module; all geometry from
// the pure `component_geometry` module. DAW-verified, not unit-tested.
#include "draw_kit.h"
#include <cstddef>
#include "bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure)
// SWELL / LICE. On Windows use native Win32 (windows.h first); on mac/linux SWELL is
// provided by the host. Mirrors bank_panel.cpp's include discipline.
#ifdef _WIN32
#include <windows.h>
#else
#include "swell/swell.h"
#endif
#include "wdltypes.h"
#include "lice/lice.h"
#include "lice/lice_text.h"
namespace reasampler {
namespace {
// --- KitColor <-> LICE boundary ----------------------------------------------
// The one place a pure KitColor becomes a LICE_pixel. Verified packing: LICE_RGBA(r,g,b,a)
// (lice.h:57). The theme owns the color; the shell owns the packing.
LICE_pixel toLice(const KitColor& c) {
return LICE_RGBA(c.r, c.g, c.b, c.a);
}
// The draw alpha the LICE primitives take (0..1), from the KitColor's 8-bit alpha. Used so
// a disabled surface (alpha 0.4) composites at the right opacity — LICE_FillRect etc. take
// a float alpha argument separate from the pixel's own alpha byte.
float drawAlpha(const KitColor& c) { return c.a / 255.0f; }
// --- Font set (owned by the kit) ---------------------------------------------
struct KitFonts {
LICE_CachedFont title;
LICE_CachedFont label;
LICE_CachedFont valueMono;
LICE_CachedFont micro;
bool ready = false;
};
KitFonts g_fonts;
// Creates one HFONT and hands it to a cached font with OWNS_HFONT so the cached font frees
// it (lice_text.h:41). Negative lfHeight = point-ish pixel height (Win32 convention). The
// face is chosen here so a change is one line.
void loadFont(LICE_CachedFont& dst, int pxHeight, int weight, const char* face) {
HFONT hf = CreateFont(-pxHeight, 0, 0, 0, weight, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, face);
if (!hf) return; // dst stays with no HFONT; DrawText on it renders nothing (safe)
dst.SetFromHFont(hf, LICE_FONT_FLAG_OWNS_HFONT);
dst.SetBkMode(TRANSPARENT);
}
LICE_CachedFont* fontFor(Font f) {
if (!g_fonts.ready) return nullptr;
switch (f) {
case Font::Title: return &g_fonts.title;
case Font::Label: return &g_fonts.label;
case Font::ValueMono: return &g_fonts.valueMono;
case Font::Micro: return &g_fonts.micro;
}
return nullptr;
}
UINT alignFlag(Align a) {
switch (a) {
case Align::Left: return DT_LEFT;
case Align::Center: return DT_CENTER;
case Align::Right: return DT_RIGHT;
}
return DT_LEFT;
}
// A 1px inner highlight on the top edge and shadow on the bottom edge — the vwnd trick
// that gives a flat fill dimension (§2.2). Lightens the top row, darkens the bottom row.
void innerEdges(LICE_IBitmap* bmp, const KitBox& b, float alpha) {
if (b.width < 2 || b.height < 2) return;
const LICE_pixel hi = LICE_RGBA(255, 255, 255, 255);
const LICE_pixel lo = LICE_RGBA(0, 0, 0, 255);
// Top inner highlight (subtle) and bottom inner shadow (subtle), inset 1px from the
// vertical edges so corners read clean.
LICE_Line(bmp, b.x + 1, b.y, b.x + b.width - 2, b.y, hi, 0.10f * alpha, 0, false);
LICE_Line(bmp, b.x + 1, b.y + b.height - 1, b.x + b.width - 2, b.y + b.height - 1,
lo, 0.22f * alpha, 0, false);
}
// The kit's core surface fill: a top-down micro-gradient (a few percent lighter at the
// top) + the inner highlight/shadow. Used by fillSurface and the component draws.
void fillGradient(LICE_IBitmap* bmp, const KitBox& b, const KitColor& top,
const KitColor& bottom) {
if (b.empty()) return;
const float a = drawAlpha(top);
// LICE_GradRect wants initial R/G/B/A (0..1) and per-axis deltas. Verified signature
// lice.h:466 — ir..ia are the top-left color; drdy..dady ramp DOWN the height so the
// bottom row reaches `bottom`. No horizontal ramp (drdx.. = 0).
const float ir = top.r / 255.0f, ig = top.g / 255.0f, ib = top.b / 255.0f;
const float dr = (bottom.r - top.r) / 255.0f;
const float dg = (bottom.g - top.g) / 255.0f;
const float db = (bottom.b - top.b) / 255.0f;
const float h = static_cast<float>(b.height);
LICE_GradRect(bmp, b.x, b.y, b.width, b.height,
ir, ig, ib, a,
0.0f, 0.0f, 0.0f, 0.0f, // no per-x ramp
dr / h, dg / h, db / h, 0.0f, // per-y ramp: top -> bottom
LICE_BLIT_MODE_COPY);
innerEdges(bmp, b, a);
}
// A surface color and its gradient partner (a few percent lighter at the top). Elevation
// reads as a subtle top-lightening of the same hue.
void gradientPair(const KitColor& base, KitColor& top, KitColor& bottom) {
top = base;
// Lighten the top ~7% (clamped by the theme's own values staying < 255 in practice).
auto lighten = [](int v) { int r = v + (v * 7) / 100 + 4; return r > 255 ? 255 : r; };
top.r = static_cast<unsigned char>(lighten(base.r));
top.g = static_cast<unsigned char>(lighten(base.g));
top.b = static_cast<unsigned char>(lighten(base.b));
bottom = base;
}
RECT toRect(const KitBox& b) {
return RECT{b.x, b.y, b.x + b.width, b.y + b.height};
}
} // namespace
// --- Font lifecycle ----------------------------------------------------------
void kitFontsInit() {
if (g_fonts.ready) return; // idempotent
// §3.1 type scale: title ~15px semibold, label ~12px, value-mono ~12px tabular,
// micro ~10px. Segoe UI (universal on the Windows target); Consolas for numerics.
loadFont(g_fonts.title, 15, FW_SEMIBOLD, "Segoe UI");
loadFont(g_fonts.label, 12, FW_NORMAL, "Segoe UI");
loadFont(g_fonts.valueMono, 12, FW_NORMAL, "Consolas");
loadFont(g_fonts.micro, 10, FW_NORMAL, "Segoe UI");
g_fonts.ready = true;
}
void kitFontsShutdown() {
if (!g_fonts.ready) return; // idempotent
// LICE_CachedFont's destructor frees its OWNS_HFONT HFONT. Re-assigning an empty font
// via SetFromHFont(nullptr) would leak nothing but also do nothing useful; instead we
// mark not-ready and let the fonts release their HFONTs when g_fonts is reset. Because
// g_fonts is a static instance (not re-created), free the HFONTs explicitly by handing
// each a null font, which OWNS semantics clean up the prior HFONT (lice_text.h:41).
g_fonts.title.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
g_fonts.label.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
g_fonts.valueMono.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
g_fonts.micro.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
g_fonts.ready = false;
}
// --- Text --------------------------------------------------------------------
void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
Font font, const KitColor& color, Align align) {
if (!bmp || !str || box.empty()) return;
LICE_CachedFont* f = fontFor(font);
if (!f) return; // before init or font-create failed: draw nothing (safe)
f->SetTextColor(toLice(color));
f->SetBkMode(TRANSPARENT);
RECT rc = toRect(box);
f->DrawText(bmp, str, -1, &rc,
alignFlag(align) | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
}
void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
Font font, Role role, Align align) {
text(bmp, box, str, font, roleColor(role), align);
}
// --- Surfaces + components ----------------------------------------------------
void fillSurface(LICE_IBitmap* bmp, const KitBox& box, Role role, InteractionState state) {
if (!bmp || box.empty()) return;
const KitColor base = roleColorState(role, state);
KitColor top, bottom;
gradientPair(base, top, bottom);
fillGradient(bmp, box, top, bottom);
}
void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label,
InteractionState state, bool warn) {
const KitBox& b = button.box;
if (!bmp || b.empty()) return;
const Role surfaceRole = warn ? Role::Warn : Role::BgCell;
const KitColor base = roleColorState(surfaceRole, state);
KitColor top, bottom;
gradientPair(base, top, bottom);
// Rounded surface: fill the interior gradient, then an AA rounded border. Corner
// radius scales gently with height, clamped so tiny buttons stay legible.
fillGradient(bmp, b, top, bottom);
const int radius = b.height >= 20 ? 5 : (b.height >= 12 ? 3 : 2);
const KitColor borderCol =
(state == InteractionState::Active || state == InteractionState::Focus)
? roleColor(Role::Accent)
: roleColor(Role::LineHairline);
LICE_RoundRect(bmp, static_cast<float>(b.x), static_cast<float>(b.y),
static_cast<float>(b.width - 1), static_cast<float>(b.height - 1),
radius, toLice(borderCol), drawAlpha(borderCol), 0, true);
if (label && *label) {
// Active fill is the accent — draw its label in the base bg for contrast; else
// text/primary (disabled dims via the state on the surface, label stays primary
// but the whole control reads recessed).
const Role textRole = (state == InteractionState::Active)
? Role::BgBase
: Role::TextPrimary;
text(bmp, b, label, Font::Label, textRole, Align::Center);
}
}
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state) {
if (!bmp || geom.track.empty()) return;
// Track groove: the cell surface, recessed (pressed-ish) so it reads as a channel.
fillSurface(bmp, geom.track, Role::BgCell, InteractionState::Pressed);
// Filled portion up to the handle: the accent (hover/dragging brighten it).
if (!geom.filled.empty()) {
const InteractionState fillState =
(state == InteractionState::Hover || state == InteractionState::Dragging)
? InteractionState::Hover
: InteractionState::Active;
KitColor top, bottom;
gradientPair(roleColorState(Role::Accent, fillState), top, bottom);
fillGradient(bmp, geom.filled, top, bottom);
}
// Handle: a raised knob honoring state.
if (!geom.handle.empty()) {
const KitButtonBox knob{geom.handle};
drawButton(bmp, knob, nullptr, state, /*warn=*/false);
}
}
void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label,
int thumbWidth, InteractionState state) {
const KitBox& b = row.box;
if (!bmp || b.empty()) return;
// Row surface: bg/cell transformed by state (hover lightens, active = accent).
fillSurface(bmp, b, Role::BgCell, state);
// Focus ring: a 1px text/primary rectangle, distinct from the accent selection fill.
if (state == InteractionState::Focus) {
const KitColor ring = roleColor(Role::TextPrimary);
LICE_DrawRect(bmp, b.x, b.y, b.width - 1, b.height - 1,
toLice(ring), drawAlpha(ring), 0);
}
// Label in the width after the reserved thumbnail inset. Active rows draw the label in
// bg/base for contrast against the accent fill; else text/primary.
if (label && *label) {
const int inset = thumbWidth > 0 ? thumbWidth + 6 : 6;
KitBox labelBox{b.x + inset, b.y, b.width - inset - 6, b.height};
if (!labelBox.empty()) {
const Role tr = (state == InteractionState::Active) ? Role::BgBase
: Role::TextPrimary;
text(bmp, labelBox, label, Font::Label, tr, Align::Left);
}
}
}
void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
if (!bmp || box.empty()) return;
const LICE_pixel midCol = toLice(roleColor(Role::LineHairline));
const LICE_pixel waveCol = toLice(roleColor(Role::Accent));
if (env.empty()) {
const int midY = box.y + box.height / 2;
LICE_Line(bmp, box.x + 2, midY, box.x + box.width - 2, midY,
midCol, 1.0f, 0, false);
return;
}
const int channels = static_cast<int>(env.size());
const int bandH = box.height / channels;
for (int ch = 0; ch < channels; ++ch) {
const ChannelEnvelope& bins = env[static_cast<std::size_t>(ch)];
const int bandTop = box.y + ch * bandH;
const int midY = bandTop + bandH / 2;
const double halfSpan = (bandH / 2) - 2;
LICE_Line(bmp, box.x + 2, midY, box.x + box.width - 2, midY,
midCol, 1.0f, 0, false);
const int nbins = static_cast<int>(bins.size());
if (nbins <= 0) continue;
const int innerW = box.width - 4;
for (int i = 0; i < nbins; ++i) {
const int x = box.x + 2 +
(nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
// Same dB display compression as the panel thumbnail (bank_grid, pure) so a
// waveform reads identically wherever the kit draws it.
int yMax = midY - static_cast<int>(
compressAmplitudeForDisplay(bins[static_cast<std::size_t>(i)].max) * halfSpan);
int yMin = midY - static_cast<int>(
compressAmplitudeForDisplay(bins[static_cast<std::size_t>(i)].min) * halfSpan);
if (yMax < bandTop) yMax = bandTop;
if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
LICE_Line(bmp, x, yMin, x, yMax, waveCol, 1.0f, 0, false);
}
}
}
} // namespace reasampler
+112
View File
@@ -0,0 +1,112 @@
#pragma once
// draw_kit — the LICE-facing SHELL half of the shared drawing kit (Phase L, L1). This is
// the ONE source of drawing for the whole system: every surface (bank_panel now; the VST
// editor + embed strip at L3) fills, buttons, rows, sliders, waveforms, and — above all —
// draws TEXT through this kit, so a control looks identical everywhere because it is the
// same kit function. It replaces the flat LICE_FillRect blocks and raw-GDI DrawTextA with
// gradient/AA surfaces (the vwnd micro-gradient + inner highlight/shadow trick) and cached
// anti-aliased text (LICE_CachedFont), honoring the interaction-state model.
//
// PURE/SHELL SPLIT (CLAUDE.md §load-bearing): this file is SHELL — it touches LICE and
// SWELL (HFONT). All palette decisions come from the pure `theme` module (role -> KitColor);
// all layout/hit-test from the pure `component_geometry` / mode_switch / etc. modules. This
// file only turns those pure answers into LICE calls. It is DAW-verified, not unit-tested.
//
// FONT LIFECYCLE (owned here): the kit holds a small set of LICE_CachedFonts (title / label
// / value-mono / micro). kitFontsInit() creates them once (from HFONTs handed off with
// LICE_FONT_FLAG_OWNS_HFONT, so the cached font frees the HFONT itself — verified in
// lice_text.h §SetFromHFont doc: "OWNS means LICE_IFont will clean up hfont on font change
// or exit"). kitFontsShutdown() deletes the cached fonts. The consumer calls init on panel
// open and shutdown on close/teardown. text() no-ops safely before init (defensive), so a
// draw that races construction never crashes.
//
// DOUBLE-BUFFER DISCIPLINE (§3.5 "zero-jank"): every function here draws into the caller's
// offscreen LICE_IBitmap; the caller BitBlt's once. Nothing here draws direct-to-DC.
#include "component_geometry.h" // KitBox / SliderGeometry — the pure geometry the shell draws
#include "peaks.h" // Envelope — the waveform primitive's input
#include "theme.h" // Role / InteractionState / KitColor / TextClass
// LICE + SWELL types at the boundary (this is the shell half). Forward-declared where
// possible to keep the header light; the .cpp includes the full LICE/SWELL headers.
class LICE_IBitmap;
namespace reasampler {
// The kit's four cached fonts (§3.1 type scale). Consumers pass a Font to text() to pick
// the size/weight; the kit maps it to the matching LICE_CachedFont.
enum class Font {
Title, // ~15px semibold — region titles, headings
Label, // ~12px regular — labels, body
ValueMono, // ~12px tabular/mono — numbers (dB/ms/notes) that must not jitter
Micro, // ~10px dim — units, counts, keybinding sub-labels
};
// Horizontal text alignment for text(). Vertical is always centered in the rect (the kit's
// single-line convention); a caller wanting multi-line composes rows itself.
enum class Align { Left, Center, Right };
// --- Font lifecycle (owned by the kit) ---------------------------------------
// Creates the four cached fonts once. Idempotent: a second call before shutdown is a no-op
// (the kit already holds live fonts). Safe to call on every panel open. Uses the platform
// UI sans (Segoe UI) for title/label/micro and a tabular mono (Consolas) for value-mono;
// the exact HFONT is created here, so a face change is a one-line edit. NO-OP-SAFE: if font
// creation fails, text() degrades to drawing nothing rather than crashing.
void kitFontsInit();
// Deletes the cached fonts (which free their owned HFONTs — LICE_FONT_FLAG_OWNS_HFONT).
// Idempotent. The consumer calls this on panel close / extension shutdown.
void kitFontsShutdown();
// --- Text (the single biggest "temple os -> modern" lever) -------------------
// Draws a single line of AA cached-font text in `color` inside `box`, horizontally aligned
// per `align` and vertically centered, clipped with an end-ellipsis. This REPLACES the
// GDI SetTextColor + DrawText path. No-op (safe) before kitFontsInit() or on a null bitmap.
void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
Font font, const KitColor& color, Align align);
// Convenience overload: text in a palette ROLE's color (the common case — the shell almost
// always wants text/primary or text/dim, not a raw color).
void text(LICE_IBitmap* bmp, const KitBox& box, const char* str,
Font font, Role role, Align align);
// --- Surfaces + components ----------------------------------------------------
// The kit's foundational fill: a micro-gradient (a few percent lighter at the top, via
// LICE_GradRect) plus a 1px inner top-highlight and bottom-shadow — the vwnd trick that
// kills the flat look (§2.2). Every button/row/cell fills through this so elevation reads
// without a border. `role` picks the surface color; `state` transforms it per the
// interaction model (hover lightens, pressed darkens, disabled desaturates, etc.).
void fillSurface(LICE_IBitmap* bmp, const KitBox& box, Role role, InteractionState state);
// A rounded, gradient-filled button with the inner highlight/shadow and a centered label,
// honoring the interaction state. `warn == true` swaps the surface to the warn role (for
// byte-deleting verbs like prune/delete) — the only place warn is drawn. A degenerate box
// is a no-op.
void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label,
InteractionState state, bool warn);
// A horizontal slider: the track groove, the accent-filled portion up to the handle, and
// the handle (a raised knob honoring state — hover/dragging brighten it). `geom` is the
// pure SliderGeometry the caller computed; the kit only draws it. Degenerate geom is a no-op.
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state);
// A selectable list row: the row surface (rest/hover/active/focus via state), an optional
// leading thumbnail area reserved at `thumbWidth` px (0 for none — the caller draws the
// thumbnail into the returned-by-convention left inset), and a left-aligned label in the
// remaining width. Focus draws a 1px text/primary ring distinct from the accent selection
// fill. A degenerate row is a no-op.
void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label,
int thumbWidth, InteractionState state);
// A waveform envelope drawn as a min/max column plot over the bg/panel surface: a midline
// per channel and an accent vertical line per bin (the same shape the panel thumbnail
// draws, lifted into the kit so the panel and the L3 editor waveform share it). `box` is
// the draw region; `env` is the per-channel min/max envelope from peaks::computeEnvelope.
// An empty env draws just the midline. The caller fills the surface first (or passes a box
// already filled); this draws only the wave + midline.
void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env);
} // namespace reasampler
+162
View File
@@ -0,0 +1,162 @@
// theme — pure implementation. See theme.h. NO REAPER / SWELL / LICE / vendor.
#include "theme.h"
#include <algorithm>
#include <cmath>
namespace reasampler {
namespace {
// ===========================================================================
// THE ONE DIRECTION CONSTANTS BLOCK (DS-2: B "Neon Console" + C 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. The values are chosen from
// the vibrant side of each WCAG floor (the "punch" rule): the accents are as
// saturated as they can be while text still clears 4.5:1 / 3:1 on the surfaces
// they land on (proven by test_theme.cpp).
// ===========================================================================
// Near-black elevation stack (B: base ~18,18,22). Each step a few points lighter
// so elevation reads without a border.
constexpr KitColor kDirBgBase {18, 18, 22, 255};
constexpr KitColor kDirBgPanel {26, 26, 31, 255};
constexpr KitColor kDirBgCell {40, 40, 46, 255};
constexpr KitColor kDirHairline {60, 60, 66, 255};
// Text: near-white primary + a dimmer secondary. Both must clear their floor on
// bg/panel AND bg/cell (the surfaces text lands on); the test enforces it.
constexpr KitColor kDirTextPrimary{224, 228, 234, 255};
constexpr KitColor kDirTextDim {150, 156, 166, 255};
// The single vivid accent — electric cyan leads (B). Bright enough to clear
// AA-large on near-black from the vibrant side; the hot tint is a lighter cyan
// for hover/live. warn is a reserved red/amber for byte-deleting states only.
constexpr KitColor kDirAccent {60, 200, 235, 255};
constexpr KitColor kDirAccentHot {130, 224, 245, 255};
constexpr KitColor kDirWarn {235, 120, 90, 255};
// Direction C spectral endpoints (cool-blue -> hot-magenta) for the keyboard strip.
constexpr KitColor kDirSpectralLo {70, 120, 235, 255}; // low notes: cool blue
constexpr KitColor kDirSpectralHi {235, 70, 170, 255}; // high notes: hot magenta
// --- 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::Accent: return kDirAccent;
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 accent itself.
return roleColor(Role::Accent);
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.
return mix(roleColor(Role::Accent), 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 accent so focus reads even pre-ring.
return mix(base, roleColor(Role::Accent), 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;
return mix(kDirSpectralLo, kDirSpectralHi, t);
}
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
+108
View File
@@ -0,0 +1,108 @@
#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, 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): every role color is produced by roleColor() from
// ONE direction constants block (kDirection*, below) carrying the settled B (Neon
// Console) + C spectral values. 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.
//
// 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 {
// 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
Accent, // selection / active / focus — where the punch lives
AccentHot, // hover / live / drag feedback (a brighter accent tint)
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: maps a normalized position t in [0, 1] (low note ->
// high note across the keyboard strip) to a color, cool-blue at 0 -> hot-magenta at 1
// (§4 Direction C). 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