Files
reasampler/src/shell/panel/draw_kit.cpp
T
daniel ca464397b2 fix: restore waveform symmetry about the midline, cut deck_values' link to the bank model, and unit-test the column arithmetic
The waveform column's vertical extents move to pure component_geometry so the shared
primitive stops being untested; PlaySeconds hoists into a header-only play_seconds target.
2026-08-01 09:47:07 -04:00

329 lines
14 KiB
C++

// draw_kit — the LICE/SWELL shell half of the drawing kit. See draw_kit.h.
// SHELL: the only kit file that touches LICE + SWELL. Colors come from `theme`;
// geometry from `component_geometry`. DAW-verified, not unit-tested.
#include "shell/panel/draw_kit.h"
#include <cstddef>
#include "core/ui/bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve
// On Windows use native Win32 (windows.h first); on mac/linux SWELL is provided by the host.
#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 {
using audio::ChannelEnvelope;
using audio::columnMinMax;
using audio::MinMax;
using ui::compressAmplitudeForDisplay;
using ui::roleColor;
using ui::roleColorState;
using ui::spectralColor;
using ui::WaveformBand;
using ui::waveformBand;
using ui::WaveformColumnSpan;
using ui::waveformColumnSpan;
// The one place a pure KitColor becomes a LICE_pixel (LICE_RGBA(r,g,b,a), verified against
// lice.h). 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);
}
namespace {
// LICE_FillRect etc. take a float alpha (0..1) separate from the pixel's own alpha byte —
// this converts a KitColor's 8-bit alpha so a disabled surface composites at the right opacity.
float drawAlpha(const KitColor& c) { return c.a / 255.0f; }
// Font::RegionTitle exists so an ACCENT-colored title can answer to the 3:1 indicator floor
// instead of the 4.5:1 body floor. That entitlement is the font's, not the color's, so the
// metrics are pinned here against theme's thresholds: shrink either one and the build stops
// rather than silently reclassifying every pair drawn in it.
constexpr int kRegionTitlePx = 19;
constexpr int kRegionTitleWeight = FW_BOLD;
static_assert(kRegionTitlePx >= ui::kLargeTextMinBoldPx, "region title must clear WCAG large");
static_assert(kRegionTitleWeight >= FW_BOLD, "the large-bold threshold requires bold, not semi");
struct KitFonts {
LICE_CachedFont regionTitle;
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::RegionTitle: return &g_fonts.regionTitle;
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 gives a flat fill
// dimension without a border.
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 (lice.h) takes initial R/G/B/A plus per-axis deltas: ir..ia are the
// top-left color, drdy..dady ramp DOWN the height so the bottom row reaches `bottom`.
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
void kitFontsInit() {
if (g_fonts.ready) return; // idempotent
loadFont(g_fonts.regionTitle, kRegionTitlePx, kRegionTitleWeight, "Segoe UI");
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
// g_fonts is a static instance, never re-created, so free the HFONTs explicitly:
// handing each a null font with OWNS_HFONT cleans up the prior HFONT (lice_text.h).
g_fonts.regionTitle.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
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;
}
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);
}
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);
fillGradient(bmp, b, top, bottom);
// Corner radius scales with height, clamped so tiny buttons stay legible.
const int radius = b.height >= 20 ? 5 : (b.height >= 12 ? 3 : 2);
const KitColor borderCol =
(state == InteractionState::Active || state == InteractionState::Focus)
? roleColor(Role::AccentPrimary)
: 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) {
text(bmp, b, label, Font::Label, buttonLabelRole(state), Align::Center);
}
}
Role buttonLabelRole(InteractionState state) {
// Active fill is the accent — the mark goes in bg/base for contrast; else text/primary.
return state == InteractionState::Active ? Role::BgBase : Role::TextPrimary;
}
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state) {
if (!bmp || geom.track.empty()) return;
fillSurface(bmp, geom.track, Role::BgCell, InteractionState::Pressed);
if (!geom.filled.empty()) {
const InteractionState fillState =
(state == InteractionState::Hover || state == InteractionState::Dragging)
? InteractionState::Hover
: InteractionState::Active;
KitColor top, bottom;
gradientPair(roleColorState(Role::AccentPrimary, fillState), top, bottom);
fillGradient(bmp, geom.filled, top, bottom);
}
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;
fillSurface(bmp, b, Role::BgCell, state);
// Focus ring is text/primary, 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);
}
// Active rows draw the label in bg/base for contrast against the accent fill.
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::AccentPrimary));
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;
const int innerW = waveformColumnCount(box); // columns: box.x+2 .. box.x+2+innerW-1
for (int ch = 0; ch < channels; ++ch) {
const ChannelEnvelope& bins = env[static_cast<std::size_t>(ch)];
const WaveformBand band = waveformBand(box.y + ch * bandH, bandH);
LICE_Line(bmp, box.x + 2, band.midY, box.x + box.width - 2, band.midY,
midCol, 1.0f, 0, false);
if (bins.empty() || innerW <= 0) continue;
// One filled span per pixel column (see draw_kit.h — gap-free via columnMinMax), then
// an antialiased stroke joining each column's extremes to its neighbour's. The fill
// alone leaves the outline stepped — a vertical span has no aa to apply — and where two
// adjacent columns differ sharply it reads as a comb rather than one envelope. The
// stroke is the SAME ink as the fill it edges, so it can only soften the boundary.
WaveformColumnSpan prev;
for (int col = 0; col < innerW; ++col) {
const MinMax mm = columnMinMax(bins, innerW, col);
const int x = box.x + 2 + col;
const WaveformColumnSpan s = waveformColumnSpan(
band, compressAmplitudeForDisplay(mm.max), compressAmplitudeForDisplay(mm.min));
LICE_Line(bmp, x, s.bottom, x, s.top, waveCol, 1.0f, 0, false);
if (col > 0) {
const float xPrev = static_cast<float>(x - 1);
const float xF = static_cast<float>(x);
LICE_FLine(bmp, xPrev, static_cast<float>(prev.topF), xF,
static_cast<float>(s.topF), waveCol, 1.0f, 0, true);
LICE_FLine(bmp, xPrev, static_cast<float>(prev.bottomF), xF,
static_cast<float>(s.bottomF), waveCol, 1.0f, 0, true);
}
prev = s;
}
}
}
} // namespace reasampler