From 774d753b4c7f9776f104705de883de3cb8722b18 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 05:30:44 -0400 Subject: [PATCH] Phase L L3: restyle VST editor + embed strip through the L1 kit (B pastel + spectral strip) --- CMakeLists.txt | 13 +- src/vst/reasampler_editor.cpp | 542 ++++++++++++++++++++++++---------- src/vst/reasampler_editor.h | 45 +++ src/vst/reasampler_embed.cpp | 93 +++--- tests/test_theme.cpp | 18 ++ 5 files changed, 506 insertions(+), 205 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 351fbc8..44e70ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 34ff11a..b66a9d4 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -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(ch.size()); - for (int x = 0; x < r.width() && x < bins; ++x) { - const MinMax& mm = ch[static_cast(x)]; - const int yTop = mid - static_cast(mm.max * halfH); - const int yBot = mid - static_cast(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(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(i - 1)].displayName; const bool active = (i == 0) ? activeFilterBankId_.empty() : (banks_[static_cast(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(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& pcm = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(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(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(map_.zones.size()); ++i) { const PerformanceZone& z = map_.zones[static_cast(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(map_.zones.size())) { const PerformanceZone& z = map_.zones[static_cast(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(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(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + const int card = (tab >= 0) + ? -1 + : cardHitTest(bl, static_cast(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(map_.zones.size())) { + play = map_.zones[static_cast(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 descs = controlDescs(play); + const std::vector 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. diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 68b57b4..8f657c4 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -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 diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp index fe67a34..6e739fd 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/vst/reasampler_embed.cpp @@ -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 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(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); } } diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 1d16075..3885752 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -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();