From beca6487c77946498f905d9d5f473eda36717cfd Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:04:04 -0400 Subject: [PATCH] =?UTF-8?q?L1:=20shared=20LICE=20drawing=20kit=20=E2=80=94?= =?UTF-8?q?=20theme/palette=20+=20component=20geometry=20+=20draw=20kit;?= =?UTF-8?q?=20retire=20GDI=20text=20in=20bank=5Fpanel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 51 ++++- src/bank_panel.cpp | 82 ++++---- src/component_geometry.cpp | 103 ++++++++++ src/component_geometry.h | 129 ++++++++++++ src/draw_kit.cpp | 323 ++++++++++++++++++++++++++++++ src/draw_kit.h | 112 +++++++++++ src/theme.cpp | 162 +++++++++++++++ src/theme.h | 108 ++++++++++ tests/test_component_geometry.cpp | 197 ++++++++++++++++++ tests/test_theme.cpp | 169 ++++++++++++++++ 10 files changed, 1394 insertions(+), 42 deletions(-) create mode 100644 src/component_geometry.cpp create mode 100644 src/component_geometry.h create mode 100644 src/draw_kit.cpp create mode 100644 src/draw_kit.h create mode 100644 src/theme.cpp create mode 100644 src/theme.h create mode 100644 tests/test_component_geometry.cpp create mode 100644 tests/test_theme.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1feb278..b54657f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -329,6 +329,32 @@ target_include_directories(action_buttons PUBLIC src) add_library(drag_out STATIC src/drag_out.cpp) target_include_directories(drag_out PUBLIC src) +# --------------------------------------------------------------------------- +# 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette +# core of the shared drawing kit: a ROLE-based color model (bg/base..warn), the +# interaction-state transform (rest/hover/active/pressed/dragging/focus/disabled), +# the Direction C spectral hue ramp, and the WCAG contrast math that lets a unit +# test prove every text-on-surface pair clears its floor ("punch to the floor"). +# THE SINGLE POINT OF CHANGE (DS-2): one direction constants block feeds roleColor; +# switching the visual direction is a one-file edit. The draw shell (draw_kit) turns +# a KitColor into a LICE_pixel at the boundary. Mirror of mode_switch — pure. +# --------------------------------------------------------------------------- +add_library(theme STATIC src/theme.cpp) +target_include_directories(theme PUBLIC src) + +# --------------------------------------------------------------------------- +# 2n) Pure component_geometry library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) +# generic component geometry the kit draws against: button box (inset + graceful +# suppression), horizontal slider track/handle/filled geometry + value<->px inverse, +# and list-row rect + hover hit-test. 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 THEIR surfaces). Names KitBox/KitButtonBox/ +# SliderGeometry/ListRowBox avoid the existing ButtonRect/CellRect collisions. +# Mirror of prune_button — pure, CTest-covered. +# --------------------------------------------------------------------------- +add_library(component_geometry STATIC src/component_geometry.cpp) +target_include_directories(component_geometry PUBLIC src) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -431,18 +457,30 @@ add_executable(drag_out_tests tests/test_drag_out.cpp) target_link_libraries(drag_out_tests PRIVATE drag_out) add_test(NAME drag_out_tests COMMAND drag_out_tests) +add_executable(theme_tests tests/test_theme.cpp) +target_link_libraries(theme_tests PRIVATE theme) +add_test(NAME theme_tests COMMAND theme_tests) + +add_executable(component_geometry_tests tests/test_component_geometry.cpp) +target_link_libraries(component_geometry_tests PRIVATE component_geometry) +add_test(NAME component_geometry_tests COMMAND component_geometry_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- -# LICE sources the bank_panel draws with: lice.cpp (LICE_SysBitmap, FillRect, -# Clear, Blit) + lice_line.cpp (Line, DrawRect). lice_line.cpp's bezier helpers -# call LICE_FillCircle from lice_arc.cpp, so that TU is required to link even -# though the panel draws no arcs. LICE routes GDI through native Win32 or, on -# mac/linux, the host SWELL (SWELL_PROVIDED_BY_APP). +# LICE sources the bank_panel + draw_kit draw with: lice.cpp (LICE_SysBitmap, FillRect, +# GradRect, Clear, Blit) + lice_line.cpp (Line, DrawRect, RoundRect). lice_line.cpp's +# bezier helpers call LICE_FillCircle from lice_arc.cpp, so that TU is required to link. +# lice_textnew.cpp provides LICE_CachedFont (the kit's AA cached-font engine, Phase L L1 — +# the "temple os -> modern" text lever; see draw_kit.cpp). NOTE the "new" variant: it is the +# TU that implements the LICE_CachedFont CLASS (lice_text.cpp is the legacy bitmap-font +# renderer with no class and would not resolve the symbols). LICE routes GDI through native +# Win32 or, on mac/linux, the host SWELL (SWELL_PROVIDED_BY_APP). set(LICE_SRC ${WDL_INC}/lice/lice.cpp ${WDL_INC}/lice/lice_line.cpp ${WDL_INC}/lice/lice_arc.cpp + ${WDL_INC}/lice/lice_textnew.cpp ) add_library(reaper_reasampler MODULE @@ -452,6 +490,7 @@ add_library(reaper_reasampler MODULE src/realtime_record.cpp src/persist.cpp src/bank_panel.cpp + src/draw_kit.cpp src/mode_switch.cpp src/tab_strip.cpp src/insert.cpp @@ -470,7 +509,7 @@ add_library(reaper_reasampler MODULE src/owned_manifest.cpp src/drag_out_win.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index a2453c6..8c0d875 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -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(GetRValue(c)), + static_cast(GetGValue(c)), + static_cast(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(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 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(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; } diff --git a/src/component_geometry.cpp b/src/component_geometry.cpp new file mode 100644 index 0000000..1951b91 --- /dev/null +++ b/src/component_geometry.cpp @@ -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(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(px - trackStart) / static_cast(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 diff --git a/src/component_geometry.h b/src/component_geometry.h new file mode 100644 index 0000000..875d5c6 --- /dev/null +++ b/src/component_geometry.h @@ -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 diff --git a/src/draw_kit.cpp b/src/draw_kit.cpp new file mode 100644 index 0000000..7db22b7 --- /dev/null +++ b/src/draw_kit.cpp @@ -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 + +#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 +#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(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(lighten(base.r)); + top.g = static_cast(lighten(base.g)); + top.b = static_cast(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(b.x), static_cast(b.y), + static_cast(b.width - 1), static_cast(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(env.size()); + const int bandH = box.height / channels; + for (int ch = 0; ch < channels; ++ch) { + const ChannelEnvelope& bins = env[static_cast(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(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( + compressAmplitudeForDisplay(bins[static_cast(i)].max) * halfSpan); + int yMin = midY - static_cast( + compressAmplitudeForDisplay(bins[static_cast(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 diff --git a/src/draw_kit.h b/src/draw_kit.h new file mode 100644 index 0000000..446d4f4 --- /dev/null +++ b/src/draw_kit.h @@ -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 diff --git a/src/theme.cpp b/src/theme.cpp new file mode 100644 index 0000000..276bb6a --- /dev/null +++ b/src/theme.cpp @@ -0,0 +1,162 @@ +// theme — pure implementation. See theme.h. NO REAPER / SWELL / LICE / vendor. + +#include "theme.h" + +#include +#include + +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(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(std::lround(a.r + (b.r - a.r) * t))), + clamp8(static_cast(std::lround(a.g + (b.g - a.g) * t))), + clamp8(static_cast(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(std::lround(c.r * factor))), + clamp8(static_cast(std::lround(c.g * factor))), + clamp8(static_cast(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( + std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b))); + const KitColor g{static_cast(gray), + static_cast(gray), + static_cast(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::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 diff --git a/src/theme.h b/src/theme.h new file mode 100644 index 0000000..38d6bfc --- /dev/null +++ b/src/theme.h @@ -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 + +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 diff --git a/tests/test_component_geometry.cpp b/tests/test_component_geometry.cpp new file mode 100644 index 0000000..f39f319 --- /dev/null +++ b/tests/test_component_geometry.cpp @@ -0,0 +1,197 @@ +// Standalone tests for reasampler::component_geometry — no REAPER, no LICE, no framework. +// Same fast assert loop as the sibling pure tests (prune_button / mode_switch). +// +// Covers (brief §L1 point 2 + §test cases): button box inset + graceful suppression; slider +// track/filled/handle geometry for representative values incl. endpoints, value->px inverse, +// too-small/degenerate suppression; list-row rect for representative indices, partial last +// row, hover hit-test returns the right row and "no hit" outside/past the last row; and the +// shared half-open box hit-test agrees with layout (no double-claimed pixel). + +#include "../src/component_geometry.h" + +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- hitTestBox (the shared primitive) --------------------------------------- + +static void testHitTestBoxHalfOpen() { + KitBox b{10, 20, 30, 40}; + CHECK(hitTestBox(10, 20, b)); // top-left inclusive + CHECK(hitTestBox(39, 59, b)); // bottom-right inclusive (x+w-1, y+h-1) + CHECK(!hitTestBox(40, 20, b)); // right edge excluded + CHECK(!hitTestBox(10, 60, b)); // bottom edge excluded + CHECK(!hitTestBox(9, 20, b)); // just left + CHECK(!hitTestBox(10, 19, b)); // just above + KitBox empty{}; + CHECK(!hitTestBox(0, 0, empty)); // empty claims nothing +} + +// --- Button box -------------------------------------------------------------- + +static void testButtonBoxInset() { + KitBox cell{0, 0, 100, 40}; + const KitButtonBox b = computeButtonBox(cell, 4); + CHECK(!b.box.empty()); + CHECK((b.box == KitBox{4, 4, 92, 32})); +} + +static void testButtonBoxZeroPadding() { + KitBox cell{5, 6, 20, 10}; + const KitButtonBox b = computeButtonBox(cell, 0); + CHECK((b.box == cell)); // no inset -> the whole cell +} + +static void testButtonBoxNegativePaddingTreatedAsZero() { + KitBox cell{5, 6, 20, 10}; + CHECK((computeButtonBox(cell, -8).box == cell)); +} + +static void testButtonBoxSuppressedWhenPaddingCollapses() { + KitBox cell{0, 0, 10, 10}; + CHECK(computeButtonBox(cell, 5).box.empty()); // 10 - 2*5 = 0 width -> suppressed + CHECK(computeButtonBox(cell, 6).box.empty()); // negative -> suppressed + CHECK(computeButtonBox(KitBox{}, 2).box.empty()); // degenerate cell -> suppressed +} + +// --- Slider ------------------------------------------------------------------ + +// control 200x20, handle 12, track thickness 4. half = 6. track.x = 6, track.width = +// 200-12 = 188, track.y = (20-4)/2 = 8. At value 0 the handle centre is track.x=6 -> +// handle.x = 0; filled.width = 0. At value 1 the centre is 6+188=194 -> handle.x=188. +static void testSliderEndpoints() { + KitBox ctrl{0, 0, 200, 20}; + const SliderGeometry lo = computeSlider(ctrl, 0.0, 12, 4); + CHECK(!lo.track.empty()); + CHECK((lo.track == KitBox{6, 8, 188, 4})); + CHECK(lo.handle.x == 0); // flush left + CHECK(lo.filled.width == 0); // nothing filled at 0 + const SliderGeometry hi = computeSlider(ctrl, 1.0, 12, 4); + CHECK(hi.handle.x == 188); // flush right (200-12) + CHECK(hi.filled.width == 188); // fully filled at 1 +} + +static void testSliderMidpoint() { + KitBox ctrl{0, 0, 200, 20}; + const SliderGeometry m = computeSlider(ctrl, 0.5, 12, 4); + // centre = 6 + round(0.5*188) = 6 + 94 = 100; handle.x = 100 - 6 = 94. + CHECK(m.handle.x == 94); + CHECK(m.filled.width == 94); +} + +static void testSliderClampsValue() { + KitBox ctrl{0, 0, 200, 20}; + CHECK((computeSlider(ctrl, -1.0, 12, 4).handle.x == computeSlider(ctrl, 0.0, 12, 4).handle.x)); + CHECK((computeSlider(ctrl, 5.0, 12, 4).handle.x == computeSlider(ctrl, 1.0, 12, 4).handle.x)); +} + +static void testSliderSuppressedWhenTooSmallOrDegenerate() { + CHECK(computeSlider(KitBox{0, 0, 8, 20}, 0.5, 12, 4).track.empty()); // width < handle + CHECK(computeSlider(KitBox{0, 0, 200, 8}, 0.5, 12, 4).track.empty()); // height < handle + CHECK(computeSlider(KitBox{}, 0.5, 12, 4).track.empty()); // degenerate + CHECK(computeSlider(KitBox{0, 0, 200, 20}, 0.5, 0, 4).track.empty()); // no handle + CHECK(computeSlider(KitBox{0, 0, 200, 20}, 0.5, 12, 0).track.empty()); // no track +} + +// value->px is the inverse of the handle placement (a track jump). +static void testSliderValueAtInverse() { + KitBox ctrl{0, 0, 200, 20}; + CHECK(sliderValueAt(6, ctrl, 12) == 0.0); // at/left of track start + CHECK(sliderValueAt(0, ctrl, 12) == 0.0); // left of the control -> 0 + CHECK(sliderValueAt(194, ctrl, 12) == 1.0); // at track end -> 1 + CHECK(sliderValueAt(300, ctrl, 12) == 1.0); // past the end -> clamped + const double mid = sliderValueAt(100, ctrl, 12); // (100-6)/188 + CHECK(mid > 0.49 && mid < 0.51); + CHECK(sliderValueAt(50, KitBox{0, 0, 8, 20}, 12) == 0.0); // too small -> 0 +} + +// --- List row ---------------------------------------------------------------- + +static void testListRowStacking() { + KitBox list{0, 100, 220, 90}; // 3 full rows of 30 fit + CHECK((computeListRow(list, 0, 30).box == KitBox{0, 100, 220, 30})); + CHECK((computeListRow(list, 1, 30).box == KitBox{0, 130, 220, 30})); + CHECK((computeListRow(list, 2, 30).box == KitBox{0, 160, 220, 30})); + CHECK(computeListRow(list, 0, 30).index == 0); + CHECK(computeListRow(list, 2, 30).index == 2); +} + +// A row that starts inside the list but overhangs the bottom keeps full height (caller +// clips the draw); a row starting AT/BELOW the bottom is suppressed. +static void testListRowPartialAndClipped() { + KitBox list{0, 0, 100, 50}; // rows of 30: row 0 [0,30), row 1 [30,60) overhangs + const ListRowBox partial = computeListRow(list, 1, 30); + CHECK(!partial.box.empty()); + CHECK(partial.box.y == 30); + CHECK(partial.box.height == 30); // full height; caller clips + CHECK(computeListRow(list, 2, 30).box.empty()); // starts at y=60 >= bottom -> none +} + +static void testListRowDegenerate() { + CHECK(computeListRow(KitBox{}, 0, 30).box.empty()); + CHECK(computeListRow(KitBox{0, 0, 100, 90}, -1, 30).box.empty()); + CHECK(computeListRow(KitBox{0, 0, 100, 90}, 0, 0).box.empty()); +} + +static void testListRowHitTest() { + KitBox list{0, 100, 220, 90}; // 3 rows of 30, rowCount = 3 + CHECK(hitTestListRow(10, 100, list, 30, 3) == 0); // top of row 0 + CHECK(hitTestListRow(10, 129, list, 30, 3) == 0); // bottom of row 0 (inclusive) + CHECK(hitTestListRow(10, 130, list, 30, 3) == 1); // top of row 1 + CHECK(hitTestListRow(10, 189, list, 30, 3) == 2); // last pixel of row 2 + // Misses. + CHECK(hitTestListRow(10, 99, list, 30, 3) == -1); // above the list + CHECK(hitTestListRow(10, 190, list, 30, 3) == -1); // below the list band + CHECK(hitTestListRow(-1, 100, list, 30, 3) == -1); // left of the list + CHECK(hitTestListRow(220, 100, list, 30, 3) == -1); // right edge excluded +} + +// rowCount bounds the hit: a taller list with fewer rows than fit reports the empty tail +// as a miss (no phantom row past the data). +static void testListRowHitTestBoundedByCount() { + KitBox list{0, 0, 100, 200}; // room for 6 rows of 30, but only 2 exist + CHECK(hitTestListRow(10, 10, list, 30, 2) == 0); + CHECK(hitTestListRow(10, 40, list, 30, 2) == 1); + CHECK(hitTestListRow(10, 70, list, 30, 2) == -1); // row 2 is empty tail -> miss + CHECK(hitTestListRow(10, 10, list, 30, 0) == -1); // zero rows -> always miss +} + +// Draw/hit-test agreement: every pixel inside a computed row hit-tests to that row. +static void testListRowLayoutHitAgreement() { + KitBox list{3, 7, 97, 120}; + const int rh = 24, rowCount = 4; + for (int idx = 0; idx < rowCount; ++idx) { + const ListRowBox r = computeListRow(list, idx, rh); + if (r.box.empty()) continue; + for (int py = r.box.y; py < r.box.y + r.box.height && + py < list.y + list.height; ++py) + CHECK(hitTestListRow(list.x + 1, py, list, rh, rowCount) == idx); + } +} + +int main() { + testHitTestBoxHalfOpen(); + testButtonBoxInset(); + testButtonBoxZeroPadding(); + testButtonBoxNegativePaddingTreatedAsZero(); + testButtonBoxSuppressedWhenPaddingCollapses(); + testSliderEndpoints(); + testSliderMidpoint(); + testSliderClampsValue(); + testSliderSuppressedWhenTooSmallOrDegenerate(); + testSliderValueAtInverse(); + testListRowStacking(); + testListRowPartialAndClipped(); + testListRowDegenerate(); + testListRowHitTest(); + testListRowHitTestBoundedByCount(); + testListRowLayoutHitAgreement(); + + if (g_fail == 0) std::printf("component_geometry: all tests passed\n"); + else std::printf("component_geometry: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp new file mode 100644 index 0000000..465f169 --- /dev/null +++ b/tests/test_theme.cpp @@ -0,0 +1,169 @@ +// Standalone tests for reasampler::theme — no REAPER, no LICE, no test framework. Same +// fast assert loop as the sibling pure tests. +// +// The load-bearing test: EVERY text-on-surface pair the kit actually draws clears its WCAG +// floor (AA 4.5:1 body / 3:1 large + state indicators) — the "punch to the floor, not past +// it" rule made testable (brief §1, CONTEXT.md §palette). Plus: the direction constants are +// the single point of change (structural — roleColor is the only color source), the +// interaction-state transform behaves, the WCAG math is correct against known anchors, and +// the Direction C spectral ramp interpolates its endpoints. + +#include "../src/theme.h" + +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- WCAG math against known anchors ------------------------------------------ + +static void testContrastKnownAnchors() { + const KitColor white{255, 255, 255, 255}; + const KitColor black{0, 0, 0, 255}; + // Black-on-white is the canonical 21:1. + CHECK(std::fabs(contrastRatio(white, black) - 21.0) < 0.05); + // Contrast is symmetric (order-independent). + CHECK(std::fabs(contrastRatio(white, black) - contrastRatio(black, white)) < 1e-9); + // A color against itself is 1:1 (no contrast). + CHECK(std::fabs(contrastRatio(white, white) - 1.0) < 1e-9); + // Luminance ordering: white brightest, black darkest. + CHECK(relativeLuminance(white) > relativeLuminance(black)); + CHECK(std::fabs(relativeLuminance(black)) < 1e-9); + CHECK(std::fabs(relativeLuminance(white) - 1.0) < 1e-9); +} + +// --- THE load-bearing test: every drawn text-on-surface pair clears its floor - + +// The surfaces text lands on (near-black elevation stack). +static void testTextPrimaryClearsBodyFloorOnSurfaces() { + const KitColor tp = roleColor(Role::TextPrimary); + // Primary text is body text -> 4.5:1 on every surface it is drawn on. + CHECK(contrastRatio(tp, roleColor(Role::BgBase)) >= textFloor(TextClass::Body)); + CHECK(contrastRatio(tp, roleColor(Role::BgPanel)) >= textFloor(TextClass::Body)); + CHECK(contrastRatio(tp, roleColor(Role::BgCell)) >= textFloor(TextClass::Body)); +} + +static void testTextDimClearsItsFloorOnSurfaces() { + const KitColor td = roleColor(Role::TextDim); + // Dim/secondary text is used for units/counts (large-ish, low-emphasis) -> the + // large/state floor 3:1 on the surfaces it appears on. Enforced from the vibrant side: + // it must not be dimmed BELOW the floor. + CHECK(contrastRatio(td, roleColor(Role::BgBase)) >= textFloor(TextClass::Large)); + CHECK(contrastRatio(td, roleColor(Role::BgPanel)) >= textFloor(TextClass::Large)); + CHECK(contrastRatio(td, roleColor(Role::BgCell)) >= textFloor(TextClass::Large)); +} + +static void testAccentClearsStateFloorOnBackground() { + // The accent is a UI-state indicator (selection border / active fill) -> 3:1 minimum + // on the base background, "pushed to the floor from the vibrant side". + CHECK(contrastRatio(roleColor(Role::Accent), roleColor(Role::BgBase)) + >= textFloor(TextClass::Large)); + // The hot (hover) accent is brighter still, so it also clears. + CHECK(contrastRatio(roleColor(Role::AccentHot), roleColor(Role::BgBase)) + >= textFloor(TextClass::Large)); +} + +static void testWarnClearsStateFloorOnBackground() { + // warn (destructive) must be unmistakable -> clears the state floor on the base. + CHECK(contrastRatio(roleColor(Role::Warn), roleColor(Role::BgBase)) + >= textFloor(TextClass::Large)); +} + +// Label drawn on an ACTIVE (accent-filled) surface: the shell draws it in bg/base. That +// pairing must also clear the body floor, or active buttons would be unreadable. +static void testLabelOnActiveSurfaceClearsFloor() { + const KitColor activeFill = roleColorState(Role::BgCell, InteractionState::Active); + const KitColor labelOnActive = roleColor(Role::BgBase); // what drawButton uses + CHECK(contrastRatio(labelOnActive, activeFill) >= textFloor(TextClass::Body)); +} + +// --- Single point of change (structural guarantee) ---------------------------- +// +// roleColor is the ONLY color source; there is no other public accessor that yields a +// palette color, so re-picking the direction is editing the one constants block roleColor +// reads. We assert the roles are DISTINCT (the block actually differentiates them — a +// collapsed/duplicated palette would betray a broken edit) and that the elevation stack +// is monotonic (base darkest -> cell lightest), the invariant the direction must preserve. +static void testRolesAreDistinctAndElevationMonotonic() { + CHECK(!(roleColor(Role::BgBase) == roleColor(Role::BgPanel))); + CHECK(!(roleColor(Role::BgPanel) == roleColor(Role::BgCell))); + CHECK(!(roleColor(Role::TextPrimary) == roleColor(Role::TextDim))); + CHECK(!(roleColor(Role::Accent) == roleColor(Role::Warn))); + // Elevation reads as increasing luminance base < panel < cell. + CHECK(relativeLuminance(roleColor(Role::BgBase)) < + relativeLuminance(roleColor(Role::BgPanel))); + CHECK(relativeLuminance(roleColor(Role::BgPanel)) < + relativeLuminance(roleColor(Role::BgCell))); + // Primary text is brighter than dim text (the type hierarchy). + CHECK(relativeLuminance(roleColor(Role::TextPrimary)) > + relativeLuminance(roleColor(Role::TextDim))); +} + +// --- Interaction-state transform ---------------------------------------------- + +static void testStateRestIsIdentity() { + for (Role r : {Role::BgBase, Role::BgPanel, Role::BgCell, Role::Accent}) { + CHECK(roleColorState(r, InteractionState::Rest) == roleColor(r)); + } +} + +static void testHoverLightensPressedDarkens() { + const KitColor rest = roleColor(Role::BgCell); + const KitColor hover = roleColorState(Role::BgCell, InteractionState::Hover); + const KitColor pressed = roleColorState(Role::BgCell, InteractionState::Pressed); + // Hover lightens toward the hot accent; pressed darkens. + CHECK(relativeLuminance(hover) > relativeLuminance(rest)); + CHECK(relativeLuminance(pressed) < relativeLuminance(rest)); +} + +static void testActiveIsAccent() { + CHECK(roleColorState(Role::BgCell, InteractionState::Active) == roleColor(Role::Accent)); +} + +static void testDisabledDropsAlphaAndDesaturates() { + const KitColor rest = roleColor(Role::Accent); + const KitColor dis = roleColorState(Role::Accent, InteractionState::Disabled); + // Alpha drops to ~40%. + CHECK(dis.a < rest.a); + CHECK(dis.a >= 90 && dis.a <= 110); // 255 * 0.4 ~= 102 +} + +// --- Direction C spectral ramp ------------------------------------------------ + +static void testSpectralInterpolatesEndpoints() { + const KitColor lo = spectralColor(0.0); + const KitColor hi = spectralColor(1.0); + // Low is cool (blue-dominant), high is hot (red-dominant) — the identity of the ramp. + CHECK(lo.b > lo.r); + CHECK(hi.r > hi.b); + // Midpoint sits between the endpoints on each channel. + const KitColor mid = spectralColor(0.5); + CHECK(mid.r > lo.r && mid.r < hi.r); + // Clamps out of range. + CHECK(spectralColor(-1.0) == lo); + CHECK(spectralColor(2.0) == hi); +} + +int main() { + testContrastKnownAnchors(); + testTextPrimaryClearsBodyFloorOnSurfaces(); + testTextDimClearsItsFloorOnSurfaces(); + testAccentClearsStateFloorOnBackground(); + testWarnClearsStateFloorOnBackground(); + testLabelOnActiveSurfaceClearsFloor(); + testRolesAreDistinctAndElevationMonotonic(); + testStateRestIsIdentity(); + testHoverLightensPressedDarkens(); + testActiveIsAccent(); + testDisabledDropsAlphaAndDesaturates(); + testSpectralInterpolatesEndpoints(); + + if (g_fail == 0) std::printf("theme: all tests passed\n"); + else std::printf("theme: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}