Phase L L3: restyle VST editor + embed strip through the L1 kit (B pastel + spectral strip)

This commit is contained in:
2026-07-27 05:30:44 -04:00
parent 6b643bb089
commit 774d753b4c
5 changed files with 506 additions and 205 deletions
+12 -1
View File
@@ -978,6 +978,11 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
src/vst/reasampler_editor.cpp
src/vst/reasampler_embed.cpp
src/vst/reaper_bridge.cpp
# The Phase L (L1) draw kit — the ONE source of drawing the editor + embed shells
# now consume (L3). Compiled into the MODULE (not a static lib) for the same reason
# bank_panel does: it is the only kit TU touching LICE, and its cached-font engine
# (LICE_CachedFont) needs the LICE_SRC TUs below linked into this artifact.
src/draw_kit.cpp
# SDK module entry — compiled into the module (not the static lib) so the
# InitDll/ExitDll dll exports survive the link (see vst3_sdk note above).
${VST3_SDK}/public.sdk/source/main/dllmain.cpp
@@ -1009,9 +1014,15 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface
# draws + routes against. browser_scroll pulls capture_browser transitively; param_slider +
# note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell.
# theme + component_geometry + bank_grid: the Phase L (L1) draw-kit's PURE deps (L3). The
# kit draws every editor/embed surface by palette ROLE via draw_kit.cpp (compiled into the
# module above): theme supplies role->KitColor + spectralColor, component_geometry the
# KitBox/button/slider geometry, bank_grid the shared dB display-compression the waveform
# primitive uses. All REAPER/SWELL-free.
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
waveform_view bank_sync browser_scroll note_entry param_slider)
waveform_view bank_sync browser_scroll note_entry param_slider
theme component_geometry bank_grid)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
+380 -162
View File
@@ -13,9 +13,12 @@
#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry
#include "capture_browser.h"
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3)
#include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3)
#include "editor_geometry.h" // Rect, contains
#include "ext_keys.h"
#include "keyboard_strip.h"
#include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3)
#include "note_entry.h" // S12 direct numeric note-entry parse
#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping
#include "peaks.h" // computeEnvelope
@@ -60,42 +63,31 @@ constexpr int kSetupHeight = 176; // the single-capture setup surface (labels +
constexpr int kStripBandHeight = 40;
constexpr int kWaveformHeight = 72; // the S11 waveform band inside the setup surface
// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as
// the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.)
const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255);
const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255);
const LICE_pixel kColCardBg = LICE_RGBA(44, 44, 48, 255);
const LICE_pixel kColCardSelBg = LICE_RGBA(48, 72, 64, 255);
const LICE_pixel kColCardBorder = LICE_RGBA(70, 70, 76, 255);
const LICE_pixel kColCardSelBorder = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColTabBg = LICE_RGBA(36, 36, 40, 255);
const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255);
const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255);
const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColWaveBg = LICE_RGBA(24, 24, 26, 255);
const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColStartMarker = LICE_RGBA(230, 200, 120, 255); // start point (amber)
const LICE_pixel kColLoopMarker = LICE_RGBA(120, 170, 230, 255); // loop start/end (blue)
const LICE_pixel kColLoopRegion = LICE_RGBA(120, 170, 230, 60); // loop span fill (faint)
const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255);
const COLORREF kRgbText = RGB(210, 230, 220);
const COLORREF kRgbDim = RGB(140, 150, 146);
// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette. The waveform's
// start point + the sustain-loop ends are CATEGORICAL kinds (a distinct affordance class,
// §2.1), not the live/active layer, so they take the categorical accents: start = teal
// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple.
constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
// ANSI path -- string literals must stay ASCII until the Phase L type kit lands.
void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col,
UINT fmt = DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX) {
HDC dc = bmp->getDC();
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, col);
RECT gr{r.left, r.top, r.right, r.bottom};
DrawTextA(dc, s, -1, &gr, fmt | DT_END_ELLIPSIS);
// --- Rect <-> kit adapters (Phase L, L3) -------------------------------------
//
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against `KitBox`
// (component_geometry). This is the single boundary that bridges them so every draw routes
// through the L1 kit (theme roles + draw_kit), retiring the shell's raw LICE_RGBA palette +
// GDI DrawTextA path.
KitBox toKitBox(const Rect& r) {
return KitBox{r.left, r.top, r.width(), r.height()};
}
void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
drawText(bmp, r, s, col, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
// Kit text in a palette ROLE (the common case). Left/Right/Center via Align.
void kitText(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role,
Align align = Align::Left) {
text(bmp, toKitBox(r), s, font, role, align);
}
void kitTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role) {
text(bmp, toKitBox(r), s, font, role, Align::Center);
}
// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the
@@ -109,20 +101,12 @@ std::string noteLabel(int note) {
return std::string(kNames[note % 12]) + std::to_string(octave);
}
// Draw a mono peak envelope centered vertically in `r` (mirror of bank_panel::drawThumbnail,
// single channel). Each bin is a vertical line from its min to its max about the midline.
// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3):
// midline + accent-primary min/max columns with the same dB display compression the dock
// panel thumbnail uses, so a waveform reads identically wherever it is drawn. The caller has
// already filled the surface behind it (bg/panel), matching drawWaveform's contract.
void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
if (r.width() <= 0 || r.height() <= 0 || env.empty() || env[0].empty()) return;
const ChannelEnvelope& ch = env[0];
const int mid = r.top + r.height() / 2;
const int halfH = r.height() / 2;
const int bins = static_cast<int>(ch.size());
for (int x = 0; x < r.width() && x < bins; ++x) {
const MinMax& mm = ch[static_cast<std::size_t>(x)];
const int yTop = mid - static_cast<int>(mm.max * halfH);
const int yBot = mid - static_cast<int>(mm.min * halfH);
LICE_Line(bmp, r.left + x, yTop, r.left + x, yBot, kColThumb, 1.0f, 0, false);
}
drawWaveform(bmp, toKitBox(r), env);
}
// A display name for a bank sample id from the snapshotted list ("?" if the id no longer
@@ -522,6 +506,13 @@ void ReaSamplerEditor::attachedToParent() {
classRegistered = true;
}
// Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a
// reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on
// editor close: the embed strip in the SAME binary shares the kit's process-global font
// set, so a per-view shutdown could free fonts still in use by the other view. The tiny
// static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note.
kitFontsInit();
refreshFromBank();
const ViewRect& r = getRect();
@@ -661,6 +652,50 @@ ChannelToggleRects channelToggleRects(const Rect& area) {
const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH};
return {mono, stereo};
}
// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface.
// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws
// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip
// so both read as the same spectrum. `stripArea` is the absolute strip rect.
void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) {
if (stripArea.width() <= 0 || stripArea.height() <= 0) return;
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
const int h = stripArea.height();
// A pastel spectral column per key. Each key's local x from keyRect; fill from this key's
// left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet
// backdrop the root/zone marks sit over.
for (int n = 0; n <= 127; ++n) {
const Rect k = keyRect(sl, n);
const int x0 = k.left + sx;
const int x1 = (n < 127) ? keyRect(sl, n + 1).left + sx : stripArea.right;
const int cw = (std::max)(1, x1 - x0);
const KitColor hue = spectralColor(static_cast<double>(n) / 127.0);
LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0);
}
// Faint per-octave key ticks (hairline role) for orientation.
const LICE_pixel tick = toLice(roleColor(Role::LineHairline));
for (int n = 0; n <= 127; n += 12) {
const Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + h, tick, 1.0f, 0, false);
}
}
// Draw the single-capture root marker on the strip: an accent-primary bar with a soft STATIC
// glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. Never animated.
void drawRootMarker(LICE_IBitmap* bmp, const Rect& stripArea, const StripLayout& sl, int root) {
const int sx = stripArea.left;
const int sy = stripArea.top;
const int h = stripArea.height();
const Rect marker = rootMarkerRect(sl, root);
const int mw = (std::max)(2, marker.width());
const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel glow = toLice(roleColor(Role::AccentHot));
// Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse).
LICE_FillRect(bmp, marker.left + sx - 3, sy, mw + 6, h, glow, 0.30f, 0);
LICE_FillRect(bmp, marker.left + sx, sy, mw, h, accent, 1.0f, 0);
}
} // namespace
void ReaSamplerEditor::paint(HDC hdc) {
@@ -671,13 +706,14 @@ void ReaSamplerEditor::paint(HDC hdc) {
if (w <= 0 || h <= 0) return;
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, kColBackground);
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
const EditorBands bands = computeBands(w, h);
// Title band: product name + live readout.
LICE_FillRect(&bmp, bands.title.left, bands.title.top, bands.title.width(),
bands.title.height(), kColTitleBg, 1.0f, 0);
// Title band: product name + live readout. Standard B palette — the beta channel gets NO
// distinct accent (settled 2026-07-27); the channel-derived vstPluginName is the only
// beta-vs-stable signal. bg/panel one step up from the canvas, primary-role title text.
fillSurface(&bmp, toKitBox(bands.title), Role::BgPanel, InteractionState::Rest);
std::string title = reasampler::vstPluginName(); // channel-derived (S18)
if (processor_ && processor_->bridge().isConnected()) {
if (samples_.empty()) title += " [bank empty]";
@@ -689,18 +725,27 @@ void ReaSamplerEditor::paint(HDC hdc) {
}
Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8,
bands.title.bottom};
drawText(&bmp, titleText, title.c_str(), kRgbText);
kitText(&bmp, titleText, title.c_str(), Font::Title, Role::TextPrimary);
// Toggle band: Browser | Zones.
// Toggle band: Browser | Zones — two segmented switches. Active = accent-primary fill
// ("this is live"); hover lightens the inactive segment toward accent/hot.
const bool inZones = (view_ == View::kZones);
LICE_FillRect(&bmp, bands.toggleBrowser.left, bands.toggleBrowser.top,
bands.toggleBrowser.width(), bands.toggleBrowser.height(),
inZones ? kColTabBg : kColTabActiveBg, 1.0f, 0);
LICE_FillRect(&bmp, bands.toggleZones.left, bands.toggleZones.top,
bands.toggleZones.width(), bands.toggleZones.height(),
inZones ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(&bmp, bands.toggleBrowser, "Browser", kRgbText);
drawTextCentered(&bmp, bands.toggleZones, "Zones", kRgbText);
const InteractionState browserState =
!inZones ? InteractionState::Active
: (isHovered(HoverKind::kToggleBrowser, -1) ? InteractionState::Hover
: InteractionState::Rest);
const InteractionState zonesState =
inZones ? InteractionState::Active
: (isHovered(HoverKind::kToggleZones, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(&bmp, toKitBox(bands.toggleBrowser), Role::BgCell, browserState);
fillSurface(&bmp, toKitBox(bands.toggleZones), Role::BgCell, zonesState);
// Active segment's label sits on the accent fill — draw it in bg/base for contrast
// (the tight text-on-pastel-fill pair, §4); the inactive label stays text/primary.
kitTextCentered(&bmp, bands.toggleBrowser, "Browser", Font::Label,
!inZones ? Role::BgBase : Role::TextPrimary);
kitTextCentered(&bmp, bands.toggleZones, "Zones", Font::Label,
inZones ? Role::BgBase : Role::TextPrimary);
if (view_ == View::kZones) {
paintZones(&bmp, w, h);
@@ -714,11 +759,12 @@ void ReaSamplerEditor::paint(HDC hdc) {
if (dropHintTicks_ > 0) {
const int bannerH = (std::min)(kTitleHeight + 8, h);
Rect banner{0, bands.toggleZones.bottom, w, bands.toggleZones.bottom + bannerH};
LICE_FillRect(&bmp, banner.left, banner.top, banner.width(), banner.height(),
kColTabActiveBg, 1.0f, 0);
drawTextCentered(&bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
kRgbText);
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
// hue with a dark label so it reads as "attention, not action".
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
kitTextCentered(&bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
Font::Label, Role::BgBase);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
@@ -735,11 +781,10 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
// discoverable here regardless of whether a drop ever lands on THIS window.
Rect primary{area.left, area.top, area.right, area.top + area.height() / 2};
Rect hint{area.left, primary.bottom, area.right, area.bottom};
drawTextCentered(bmp, primary, msg, kRgbDim);
drawTextCentered(bmp,
hint,
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
kRgbDim);
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
kitTextCentered(bmp, hint,
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
Font::Micro, Role::TextDim);
}
void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
@@ -755,14 +800,25 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
LICE_FillRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width(), searchAbs.height(),
searchFocused_ ? kColTabActiveBg : kColTabBg, 1.0f, 0);
// A focused search box lifts to the focus state (a nudge toward the primary accent + a
// text/primary ring drawn below); hover lightens; else the resting cell surface.
const InteractionState searchState =
searchFocused_ ? InteractionState::Focus
: (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState);
if (searchFocused_) {
const KitColor ring = roleColor(Role::TextPrimary);
LICE_DrawRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width() - 1,
searchAbs.height() - 1, toLice(ring), 1.0f, 0);
}
{
std::string sb = searchQuery_.empty()
? std::string("Search captures...")
: ("Search: " + searchQuery_ + (searchFocused_ ? "_" : ""));
Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom};
drawText(bmp, sbText, sb.c_str(), searchQuery_.empty() ? kRgbDim : kRgbText);
kitText(bmp, sbText, sb.c_str(), Font::Label,
searchQuery_.empty() ? Role::TextDim : Role::TextPrimary);
}
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
@@ -782,9 +838,13 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
const std::string label = (i == 0) ? "All" : banks_[static_cast<std::size_t>(i - 1)].displayName;
const bool active = (i == 0) ? activeFilterBankId_.empty()
: (banks_[static_cast<std::size_t>(i - 1)].id == activeFilterBankId_);
LICE_FillRect(bmp, t.left, t.top, t.width(), t.height(),
active ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(bmp, t, label.c_str(), kRgbText);
const InteractionState state =
active ? InteractionState::Active
: (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(t), Role::BgCell, state);
kitTextCentered(bmp, t, label.c_str(), Font::Label,
active ? Role::BgBase : Role::TextPrimary);
}
// Cards: only the S12 visible window at the current scroll offset (a bank longer than the
@@ -808,22 +868,29 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
const bool sel = (s.id == selectedId_);
LICE_FillRect(bmp, content.left, content.top, content.width(), content.height(),
sel ? kColCardSelBg : kColCardBg, 1.0f, 0);
// Card surface: a plain cell (hover lightens), with the SELECTED pick marked by an
// accent-primary border (the "this is live" signal, §2.1) — the same convention the
// dock panel's L7 selection uses (normal cell + accent border, no inversion). The
// waveform thumbnail draws over bg/panel so its accent columns read against the cell.
const InteractionState cardState =
isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest;
fillSurface(bmp, toKitBox(content), Role::BgCell, cardState);
const KitColor cardBorder =
sel ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline);
LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1,
sel ? kColCardSelBorder : kColCardBorder, 1.0f, 0);
toLice(cardBorder), 1.0f, 0);
drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins));
// Name + root/key badge under the thumbnail.
std::string caption = s.displayName.empty() ? s.id : s.displayName;
Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2};
Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom};
drawText(bmp, nameR, caption.c_str(), kRgbText);
kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary);
std::string badge;
if (s.rootNote) badge = "root " + noteLabel(*s.rootNote);
else if (s.key) badge = *s.key;
else badge = "root -";
drawText(bmp, badgeR, badge.c_str(), kRgbDim);
kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim);
}
// S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure
@@ -832,8 +899,10 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
{
const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_);
if (thumb.height() > 0) {
const bool dragging = (drag_ == DragKind::kScrollThumb);
const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary);
LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(),
kColRootMarker, 0.8f, 0);
toLice(tc), 0.8f, 0);
}
}
@@ -846,9 +915,8 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) {
// The guided single-capture setup: the picked capture's name + root/level, and a
// keyboard strip with its root marker (drag to set root).
LICE_FillRect(bmp, area.left, area.top, area.width(), area.height(),
kColTitleBg, 1.0f, 0);
// keyboard strip with its root marker (drag to set root). A raised bg/panel region.
fillSurface(bmp, toKitBox(area), Role::BgPanel, InteractionState::Rest);
// Effective root: the picked sample's rootNote intrinsic (or middle C when unset).
// Read from samples_ (the full unfiltered list) so a bank-filter that hides the
@@ -867,78 +935,80 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) {
const ChannelToggleRects chan = channelToggleRects(area);
Rect headerR{area.left + pad, area.top + 4, chan.mono.left - 8, area.top + 22};
std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root);
drawText(bmp, headerR, header.c_str(), kRgbText);
kitText(bmp, headerR, header.c_str(), Font::Label, Role::TextPrimary);
// S7 mono | stereo output-mode toggle. The active segment highlights (kColTabActiveBg),
// the inactive is kColTabBg — the same visual grammar as the Browser/Zones toggle.
// S7 mono | stereo output-mode toggle. Active segment = accent-primary fill (the live
// mode); inactive lightens on hover — the same visual grammar as the Browser/Zones toggle.
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
LICE_FillRect(bmp, chan.mono.left, chan.mono.top, chan.mono.width(), chan.mono.height(),
isStereo ? kColTabBg : kColTabActiveBg, 1.0f, 0);
LICE_FillRect(bmp, chan.stereo.left, chan.stereo.top, chan.stereo.width(),
chan.stereo.height(), isStereo ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(bmp, chan.mono, "Mono", kRgbText);
drawTextCentered(bmp, chan.stereo, "Stereo", kRgbText);
const InteractionState monoState =
!isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover
: InteractionState::Rest);
const InteractionState stereoState =
isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState);
fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState);
kitTextCentered(bmp, chan.mono, "Mono", Font::Label,
!isStereo ? Role::BgBase : Role::TextPrimary);
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label,
isStereo ? Role::BgBase : Role::TextPrimary);
Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16};
drawText(bmp, hintR,
"Drag the waveform markers to set start + loop; drag the keyboard to set root.",
kRgbDim);
kitText(bmp, hintR,
"Drag the waveform markers to set start + loop; drag the keyboard to set root.",
Font::Micro, Role::TextDim);
// --- S11 waveform surface: the picked capture's envelope + draggable markers ----------
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
const Rect waveArea = setupWaveformArea(area);
LICE_FillRect(bmp, waveArea.left, waveArea.top, waveArea.width(), waveArea.height(),
kColWaveBg, 1.0f, 0);
// The waveform pane is a recessed surface (bg/base, one step DOWN from the setup panel)
// so the accent-primary envelope reads against it.
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
if (frames > 0 && waveArea.width() > 0) {
// Envelope at one bin per pixel (full-res view of the decoded PCM, S10 read-only view
// reused). computeEnvelope over the cached mono frames — no new decode.
const int bins = (std::max)(1, waveArea.width());
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast<std::size_t>(bins));
drawEnvelope(bmp, waveArea, env); // reuses the thumbnail envelope draw (kColThumb)
drawEnvelope(bmp, waveArea, env); // kit drawWaveform — accent-primary columns
const SetupMarkers m = pickedMarkers(frames);
// Faint loop-region fill between the loop markers (only when a loop is set).
// Faint loop-region fill between the loop markers (only when a loop is set) — the
// categorical loop-marker hue (tertiary purple) at low alpha.
if (m.hasLoop && m.loopEnd > m.loopStart) {
const int lx = frameToX(waveArea, frames, m.loopStart);
const int rx = frameToX(waveArea, frames, m.loopEnd);
if (rx > lx) {
LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(),
kColLoopRegion, 1.0f, 0);
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
}
}
// The three markers: start (amber), loop start + loop end (blue). Drawn as 2px vertical
// lines the full waveform height. Loop markers dim when no loop is set (the "no loop"
// state — draggable to CREATE one).
// The three markers: start (teal, secondary) + loop start/end (purple, tertiary) —
// categorical affordance hues (§2.1), 2px vertical lines the full waveform height.
// Loop markers dim when no loop is set (the "no loop" state — draggable to CREATE one).
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const LICE_pixel markerCols[3] = {kColStartMarker, kColLoopMarker, kColLoopMarker};
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
for (int i = 0; i < 3; ++i) {
const int mx = frameToX(waveArea, frames, markerFrames[i]);
const bool loopMarker = (i != 0);
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(), markerCols[i],
alpha, 0);
LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(),
toLice(roleColor(markerRoles[i])), alpha, 0);
}
} else {
drawTextCentered(bmp, waveArea, "(decoding...)", kRgbDim);
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
}
// Keyboard strip with the root marker.
// Keyboard strip with the root marker — the signature Direction-C PASTEL SPECTRAL surface
// (§4). Each key column is hue-mapped low->high across the accent trio (spectralColor:
// lime -> teal -> purple), so the strip reads as an extension of the accent system. The
// root marker lifts to accent-primary with a STATIC glow (never a pulse — §3.5).
const Rect stripArea = setupStripArea(area);
drawSpectralStrip(bmp, stripArea);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(),
kColStripBg, 1.0f, 0);
// Faint per-octave key ticks for orientation.
for (int n = 0; n <= 127; n += 12) {
Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey,
1.0f, 0, false);
}
Rect marker = rootMarkerRect(sl, root);
LICE_FillRect(bmp, marker.left + sx, sy, (std::max)(2, marker.width()), stripArea.height(),
kColRootMarker, 1.0f, 0);
drawRootMarker(bmp, stripArea, sl, root);
}
void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
@@ -949,37 +1019,46 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
bands.content.top + 4 + 20};
LICE_FillRect(bmp, addR.left, addR.top, addR.width(), addR.height(), kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, addR.left, addR.top, addR.width() - 1, addR.height() - 1,
kColCardSelBorder, 1.0f, 0);
drawTextCentered(bmp, addR, "+ Add Zone", kRgbText);
{
const KitButtonBox box{toKitBox(addR)};
const InteractionState state =
isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
}
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (selectedZone_ >= 0) {
LICE_FillRect(bmp, delR.left, delR.top, delR.width(), delR.height(), kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, delR.left, delR.top, delR.width() - 1, delR.height() - 1,
kColCardSelBorder, 1.0f, 0);
drawTextCentered(bmp, delR, "Delete", kRgbText);
const KitButtonBox box{toKitBox(delR)};
const InteractionState state =
isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
// Deleting a zone is not a byte-destroying act (no file removed — the bank is
// read-only here), so it is a normal button, not `warn`.
drawButton(bmp, box, "Delete", state, /*warn=*/false);
}
// The zones strip.
// The zones strip — the same PASTEL SPECTRAL surface as the setup face (§4), with one
// bar per zone over the spectrum. The SELECTED zone lifts to accent-primary + a static
// glow ("which zone is live"); the rest take the categorical secondary hue at low alpha.
const Rect stripArea = zonesStripArea(bands);
drawSpectralStrip(bmp, stripArea);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(),
kColStripBg, 1.0f, 0);
for (int n = 0; n <= 127; n += 12) {
Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey,
1.0f, 0, false);
}
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
const int bw = (std::max)(2, bar.width());
const bool sel = (i == selectedZone_);
LICE_FillRect(bmp, bar.left + sx, sy, (std::max)(2, bar.width()), stripArea.height(),
sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0);
if (sel) {
// Static glow halo behind the live zone, then the crisp accent-primary bar.
LICE_FillRect(bmp, bar.left + sx - 2, sy, bw + 4, stripArea.height(),
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(),
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
} else {
LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(),
toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
}
}
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
@@ -989,9 +1068,10 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18};
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
drawText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
sampleLabel(samples_, z.sampleId).c_str(), kRgbText);
// Three fields laid out left-to-right after the sample label.
kitText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
sampleLabel(samples_, z.sampleId).c_str(), Font::Label, Role::TextPrimary);
// Three fields laid out left-to-right after the sample label. A focused field lifts to
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
const Rect fields = noteEntryFieldsArea(bands);
const char* names[3] = {"Low", "High", "Root"};
const std::string vals[3] = {
@@ -1000,17 +1080,21 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
for (int f = 0; f < 3; ++f) {
const Rect fr = noteEntryFieldRect(fields, f);
const bool editing = (entryField_ == f);
LICE_FillRect(bmp, fr.left, fr.top, fr.width(), fr.height(),
editing ? kColTabActiveBg : kColCardBg, 1.0f, 0);
fillSurface(bmp, toKitBox(fr), Role::BgCell,
editing ? InteractionState::Focus : InteractionState::Rest);
const KitColor border =
editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1,
kColCardBorder, 1.0f, 0);
toLice(border), 1.0f, 0);
std::string cap = std::string(names[f]) + ": " +
(editing ? (entryText_ + "_") : vals[f]);
drawText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), kRgbText);
kitText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(),
Font::ValueMono, Role::TextPrimary);
}
} else if (map_.zones.empty()) {
drawText(bmp, infoR,
"No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim);
kitText(bmp, infoR,
"No zones. Add Zone maps the picked capture across the keyboard.",
Font::Label, Role::TextDim);
}
// The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger +
@@ -1070,29 +1154,136 @@ void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) {
for (const ControlRow& r : rows) {
if (r.row.top >= panel.bottom) break; // clip at the panel bottom
const ControlLabels lab = labelsFor(static_cast<ParamControl>(r.id));
drawText(bmp, r.label, lab.label, kRgbDim);
kitText(bmp, r.label, lab.label, Font::Micro, Role::TextDim);
const double v = controlValue(r.id, play);
const bool hov = isHovered(HoverKind::kControl, r.id);
if (r.kind == ControlKind::Toggle) {
const bool seg1 = (v >= 0.5);
const Rect s0 = toggleSegmentRect(r.control, 0);
const Rect s1 = toggleSegmentRect(r.control, 1);
LICE_FillRect(bmp, s0.left, s0.top, s0.width(), s0.height(),
seg1 ? kColTabBg : kColTabActiveBg, 1.0f, 0);
LICE_FillRect(bmp, s1.left, s1.top, s1.width(), s1.height(),
seg1 ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(bmp, s0, lab.seg0, kRgbText);
drawTextCentered(bmp, s1, lab.seg1, kRgbText);
// The lit segment carries the primary accent (Active); the unlit segment hovers
// toward accent/hot when the whole control is under the pointer.
const InteractionState s0State =
!seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
const InteractionState s1State =
seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
fillSurface(bmp, toKitBox(s0), Role::BgCell, s0State);
fillSurface(bmp, toKitBox(s1), Role::BgCell, s1State);
kitTextCentered(bmp, s0, lab.seg0, Font::Micro, !seg1 ? Role::BgBase : Role::TextPrimary);
kitTextCentered(bmp, s1, lab.seg1, Font::Micro, seg1 ? Role::BgBase : Role::TextPrimary);
} else {
// Track groove (recessed cell) + accent filled portion up to the handle + a raised
// handle. Dragging THIS control brightens the fill/handle (accent/hot).
const bool dragging = (drag_ == DragKind::kParamSlider && dragParamId_ == r.id);
const Rect track = sliderTrackRect(r.control);
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, track.width(), 2,
kColStripKey, 1.0f, 0);
fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1,
track.right, track.top + track.height() / 2 + 1}),
Role::BgCell, InteractionState::Pressed);
const Rect handle = sliderHandleRect(r.control, v);
LICE_FillRect(bmp, handle.left, handle.top + 2, handle.width(), handle.height() - 4,
kColRootMarker, 1.0f, 0);
// Filled portion: track-left to the handle center.
const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left);
if (fillW > 0) {
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2,
toLice(roleColor(dragging ? Role::AccentHot : Role::AccentPrimary)),
1.0f, 0);
}
const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right,
handle.bottom - 2})};
drawButton(bmp, knob, nullptr,
dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest),
/*warn=*/false);
}
}
}
// --- Hover resolution (Phase L, L3) ------------------------------------------
//
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
// idle move is free — the "sub-frame feedback, zero cost when nothing changed" discipline).
// Mirrors onMouseDown's hit-test order, but read-only: it never mutates selection/map. Only
// the frequently-touched interactive surfaces light on hover; a purely decorative region
// resolves to kNone (clearing any prior hover). Windows-only.
void ReaSamplerEditor::resolveHover(int x, int y) {
HoverTarget h; // kNone by default
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int hgt = cr.bottom - cr.top;
const EditorBands bands = computeBands(w, hgt);
if (contains(bands.toggleBrowser, x, y)) {
h = {HoverKind::kToggleBrowser, -1};
} else if (contains(bands.toggleZones, x, y)) {
h = {HoverKind::kToggleZones, -1};
} else if (view_ == View::kBrowser) {
const bool havePick = !selectedId_.empty();
const int setupTop = havePick
? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
if (contains(searchAbs, x, y)) {
h = {HoverKind::kSearchBox, -1};
} else {
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
const int bx = x - browserArea.left;
const int by = y - browserArea.top;
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
const int card = (tab >= 0)
? -1
: cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (tab >= 0) {
h = {HoverKind::kFilterTab, tab};
} else if (card >= 0) {
h = {HoverKind::kCard, card};
} else if (havePick) {
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
const ChannelToggleRects chan = channelToggleRects(area);
if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
}
}
} else { // Zones view
const int pad = 8;
Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
bands.content.top + 4 + 20};
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (contains(addR, x, y)) {
h = {HoverKind::kAddZone, -1};
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
h = {HoverKind::kDeleteZone, -1};
} else {
// The param control panel (a selected zone, or the single-capture face's defaults).
ZonePlaySeconds play;
bool haveTarget = false;
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
haveTarget = true;
} else if (map_.zones.empty() && !selectedId_.empty()) {
play = PerformanceZone{}.play;
haveTarget = true;
}
if (haveTarget) {
const Rect panel = zonesControlPanel(bands);
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) h = {HoverKind::kControl, id};
}
}
}
if (h != hover_) {
hover_ = h;
invalidate();
}
}
// --- Input: the drag-state machine -------------------------------------------
void ReaSamplerEditor::onMouseDown(int x, int y) {
@@ -1626,7 +1817,34 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
}
return 0;
case WM_MOUSEMOVE:
if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
if (self) {
const int mx = GET_X_LPARAM(lParam);
const int my = GET_Y_LPARAM(lParam);
// Hover feedback (Phase L, L3): resolve the element under the pointer and
// repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover
// clears when the pointer leaves the child (TrackMouseEvent is one-shot).
if (!self->mouseTracking_) {
TRACKMOUSEEVENT tme{};
tme.cbSize = sizeof(tme);
tme.dwFlags = TME_LEAVE;
tme.hwndTrack = hwnd;
TrackMouseEvent(&tme);
self->mouseTracking_ = true;
}
// While a drag is in flight the drag owns the surface; skip hover resolution
// (a hover repaint mid-drag would fight the live drag feedback).
if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my);
self->onMouseMove(mx, my);
}
return 0;
case WM_MOUSELEAVE:
if (self) {
self->mouseTracking_ = false;
if (self->hover_.kind != HoverKind::kNone) {
self->hover_ = HoverTarget{};
self->invalidate();
}
}
return 0;
case WM_MOUSEWHEEL:
// S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
+45
View File
@@ -100,6 +100,34 @@ private:
// a different marker set; here it is start-point + the sustain loop's two ends.
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
// --- Hover model (Phase L, L3) ------------------------------------------------
//
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE so the kit
// draws its hover state on that element only ("hover on every interactive element" +
// "sub-frame feedback = the perception of speed", §3.3/§3.5). Cleared to kNone on
// WM_MOUSELEAVE (tracked via TrackMouseEvent). `index` disambiguates within a kind
// (tab ordinal, visible-card index, control-row id); -1 when not applicable. Mirror of
// bank_panel's L2 hover model.
enum class HoverKind {
kNone,
kToggleBrowser, // the Browser toggle segment
kToggleZones, // the Zones toggle segment
kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index)
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kAddZone, // the "+ Add Zone" button
kDeleteZone, // the "Delete" zone button
kControl, // a param-panel control row (index = ControlDesc id)
};
struct HoverTarget {
HoverKind kind = HoverKind::kNone;
int index = -1;
bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; }
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
};
#ifdef _WIN32
void paint(HDC hdc);
void paintBrowser(LICE_IBitmap* bmp, int w, int h);
@@ -111,6 +139,17 @@ private:
void onMouseDown(int x, int y);
void onMouseMove(int x, int y);
void onMouseUp(int x, int y);
// Resolve the interactive element under (x, y) into hover_ (Phase L, L3). Called from
// WM_MOUSEMOVE (also while a drag is in flight — the resolved element just isn't used
// for a hover repaint mid-drag). Repaints only when the hovered element changed, so an
// idle mouse-move is free. Windows-only (the hit-tests use the shell's Win32 client rect).
void resolveHover(int x, int y);
// True iff element (kind, index) is the live hover_ target — the shell maps this to the
// kit's Hover interaction state when the element has no more-specific state (Active, etc.).
bool isHovered(HoverKind kind, int index) const {
return hover_.kind == kind && hover_.index == index;
}
void onMouseWheel(int delta); // S12 browser scroll (wheel)
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
@@ -242,6 +281,12 @@ private:
int entryField_ = -1;
std::string entryText_;
// --- Hover state (Phase L, L3; transient, never persisted) --------------------
HoverTarget hover_; // the interactive element under the pointer
#ifdef _WIN32
bool mouseTracking_ = false; // TrackMouseEvent armed for WM_MOUSELEAVE this "over" cycle
#endif
// --- Drag-state machine ------------------------------------------------------
DragKind drag_ = DragKind::kNone;
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
+51 -42
View File
@@ -9,11 +9,14 @@
#include "app_version.h" // vstPluginName (channel-derived embed label, S18)
#include "bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh)
#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3)
#include "draw_kit.h" // the L1 draw kit: fillSurface/text (L3)
#include "editor_geometry.h" // Rect (shared with embed_strip)
#include "embed_strip.h" // the pure strip layout + hit-test
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey
#include "reaper_bridge.h"
#include "reasampler_processor.h"
#include "theme.h" // Role / InteractionState / spectralColor (L3)
// wdltypes.h first: it defines INT_PTR portably (and pulls <windows.h> on Windows), which
// reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type.
@@ -41,15 +44,12 @@ namespace reasampler::vst {
namespace {
#ifdef _WIN32
// Palette — mirrored from reasampler_editor.cpp so the inline strip reads as the same tool.
const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255);
const LICE_pixel kColZone = LICE_RGBA(44, 44, 48, 255);
const LICE_pixel kColZoneSel = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColZoneBorder = LICE_RGBA(20, 20, 22, 255);
const LICE_pixel kColLevelBg = LICE_RGBA(20, 20, 22, 255);
const LICE_pixel kColLevelFill = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColEmpty = LICE_RGBA(70, 70, 74, 255);
const COLORREF kRgbText = RGB(210, 230, 220);
// Kit adapter (Phase L, L3): the embed shell's Rect (editor_geometry) -> the kit's KitBox
// (component_geometry). Every embed surface now draws by palette ROLE via the L1 kit, retiring
// the local pre-L1 forest-green palette + raw GDI DrawTextA.
KitBox toKitBox(const Rect& r) {
return KitBox{r.left, r.top, r.width(), r.height()};
}
// A short display name for a bank sample id, from the snapshotted list (the editor's helper,
// duplicated small rather than shared across the shell/pure boundary).
@@ -136,6 +136,12 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
return 0; // not a build target off Windows
#endif
case REAPER_FXEMBED_WM_CREATE:
#ifdef _WIN32
// Create the kit's cached AA fonts before the first paint (Phase L, L3).
// Idempotent + process-global (shared with the editor in this binary); NOT torn
// down per-view — the OS reclaims the tiny static HFONT set at module unload.
kitFontsInit();
#endif
refresh(); // prime the first paint's snapshot
return 0;
case REAPER_FXEMBED_WM_DESTROY:
@@ -184,62 +190,65 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
// REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike
// the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0).
LICE_FillRect(bmp, 0, 0, w, h, kColBackground, 1.0f, 0);
// Base canvas through the kit (bg/base + micro-gradient), Phase L L3.
fillSurface(bmp, KitBox{0, 0, w, h}, Role::BgBase, InteractionState::Rest);
const EmbedLayout layout = layoutEmbed(w, h);
if (map_.zones.empty()) {
// No opt-in zones authored: show a single faint band spanning the keymap area so the
// strip reads as "present, no zones" — the default single-capture face lives in the
// editor (this S6 strip mirrors the zones map only).
// No opt-in zones authored: a faint bg/cell band spanning the keymap area so the strip
// reads as "present, no zones" — the default single-capture face lives in the editor.
LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(),
layout.keymap.height(), kColEmpty, 0.5f, 0);
HDC dc = bmp->getDC();
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, kRgbText);
RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right,
layout.keymap.bottom};
layout.keymap.height(), toLice(roleColor(Role::BgCell)), 0.5f, 0);
const std::string label = reasampler::vstPluginName() + // channel-derived (S18)
(samples_.empty() ? " (bank empty)" : " (no zones)");
DrawTextA(dc, label.c_str(), -1, &gr,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
const Rect labelR{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right,
layout.keymap.bottom};
text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left);
} else {
// Draw each zone as a segment across the keymap span, first-match order (so the
// painted order matches selection + playback). The selected zone is highlighted.
// Draw each zone as a segment across the keymap span, first-match order (so the painted
// order matches selection + playback). Each segment takes its PASTEL SPECTRAL hue from
// the center of its key span (spectralColor — §4), so the strip reads as the same
// spectrum as the editor's keyboard strip. The SELECTED zone lifts to accent-primary
// + a static glow ("which zone is live", never a pulse — §3.5).
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[i];
const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote);
if (r.width() <= 0) continue;
const bool sel = (i == selectedZone_);
LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(),
sel ? kColZoneSel : kColZone, 1.0f, 0);
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, kColZoneBorder,
1.0f, 0);
// Label the segment with the sample name when it is wide enough to read.
if (sel) {
// Static glow halo, then the crisp accent-primary fill.
LICE_FillRect(bmp, r.left - 2, r.top, r.width() + 4, r.height(),
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(),
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
} else {
const double t = ((z.lowNote + z.highNote) * 0.5) / 127.0;
LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(),
toLice(spectralColor(t)), 0.65f, 0);
}
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
// Label the segment with the sample name when it is wide enough to read. The
// selected (accent-fill) segment draws its label in bg/base for contrast (the
// tight text-on-pastel pair, §4); the rest in text/primary.
if (r.width() >= 24) {
HDC dc = bmp->getDC();
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, kRgbText);
RECT gr{r.left + 3, r.top, r.right - 2, r.bottom};
const std::string label = sampleLabel(samples_, z.sampleId);
DrawTextA(dc, label.c_str(), -1, &gr,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS);
const Rect lr{r.left + 3, r.top, r.right - 2, r.bottom};
text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(),
Font::Label, sel ? Role::BgBase : Role::TextPrimary, Align::Left);
}
}
}
// The level band: a static background here; a live activity level is a later refinement
// (the processor would publish a peak the UI thread reads). Draw the empty band so the
// strip's geometry is complete and the DAW-verify sees the band lifecycle now.
// The level band: a recessed bg/cell channel with an accent-primary fill following the
// live activity level (a direct level follow — the one permitted "motion", §3.5).
if (layout.levelBand.height() > 0) {
LICE_FillRect(bmp, layout.levelBand.left, layout.levelBand.top,
layout.levelBand.width(), layout.levelBand.height(), kColLevelBg, 1.0f,
0);
fillSurface(bmp, toKitBox(layout.levelBand), Role::BgCell, InteractionState::Pressed);
const double level = processor_ ? processor_->embedActivityLevel() : 0.0;
const Rect fill = levelFillRect(layout, level);
if (fill.width() > 0) {
LICE_FillRect(bmp, fill.left, fill.top, fill.width(), fill.height(),
kColLevelFill, 1.0f, 0);
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
}
}
+18
View File
@@ -134,6 +134,23 @@ static void testLabelOnActiveSurfaceClearsFloor() {
CHECK(contrastRatio(labelOnActive, activeFill) >= textFloor(TextClass::Body));
}
// Text drawn on a HOVER-state surface (Phase L, L3): the VST editor + embed restyle lights
// hover on inactive segments/tabs/cards by lightening bg/cell toward accent/hot (~10%).
// Lightening the surface REDUCES contrast against light text, so re-verify the pairs on the
// hover surface, not just on rest bg/cell. (A distinct drawn pair the L3 shells introduce,
// not covered by the rest-surface tests above.)
// - text/PRIMARY (card name, segment/tab labels) is BODY -> must clear AA 4.5:1 even hovered.
// - text/DIM is used ONLY as a compact UI LABEL on a hoverable surface (the card's root/key
// BADGE — the one dim-on-hover pair the restyle draws), so it clears at the 3:1 large/
// UI-label floor, per §2.1's "confine dim to large/UI-label use where the 3:1 floor
// applies." A lightened surface pulls a mid-grey dim toward the body floor; the badge is
// deliberately a label, not body, so 3:1 is the correct floor and this locks it there.
static void testTextOnHoverSurfaceClearsFloor() {
const KitColor hover = roleColorState(Role::BgCell, InteractionState::Hover);
CHECK(contrastRatio(roleColor(Role::TextPrimary), hover) >= textFloor(TextClass::Body));
CHECK(contrastRatio(roleColor(Role::TextDim), hover) >= textFloor(TextClass::Large));
}
// --- Single point of change (structural guarantee) ----------------------------
//
// roleColor is the ONLY color source; there is no other public accessor that yields a
@@ -217,6 +234,7 @@ int main() {
testTextDimClearsItsFloorOnSurfaces();
testAccentsClearIndicatorFloorOnCell();
testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable();
testWarnClearsStateFloorOnBackground();
testLabelOnActiveSurfaceClearsFloor();