dd067a192e
Fixes the critical finding — Browse/curve-popup no longer trigger 60 Hz whole-client repaints when the meter is invisible. Pushes the fast-path and self-containment geometry into master_meter with tests; batches the four minor findings.
364 lines
18 KiB
C++
364 lines
18 KiB
C++
// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing:
|
|
// platform-type/resize negotiation, the child window class + creation/destruction, the
|
|
// sync timer lifetime, the WM_* dispatch (wndProc — paint, mouse, keyboard, capture-loss
|
|
// rollback, drop-accept, timer), and the non-Windows stubs (Windows is the only build
|
|
// target; the TU still compiles elsewhere).
|
|
|
|
#include "shell/instrument/reasampler_editor.h"
|
|
|
|
#ifdef _WIN32
|
|
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
|
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — drop-accept
|
|
#endif
|
|
|
|
#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only)
|
|
#include "shell/instrument/reasampler_processor.h"
|
|
|
|
using namespace Steinberg;
|
|
|
|
namespace reasampler::vst {
|
|
|
|
#ifdef _WIN32
|
|
namespace {
|
|
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
|
|
|
|
// The change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
|
|
// timer: responsive enough that a recapture/ingest/assign refreshes within a bounded
|
|
// cadence, yet cheap — three small ext-state reads per tick in the steady state, coalescing
|
|
// many bumps between ticks into one reload. Anything costlier a tick answers (the bake-Hold
|
|
// predicate's bank parse) is memoized against its inputs, so keep it that way rather than
|
|
// letting a per-tick full read back in. 500 ms is a deliberate build-time residual. The id is
|
|
// a per-window SetTimer id (any nonzero).
|
|
constexpr UINT_PTR kSyncTimerId = 1;
|
|
constexpr UINT kSyncTimerIntervalMs = 500;
|
|
|
|
// The meter's own clock on the same child window, because the bus meter is the one surface
|
|
// whose value changes every block. Raising the poll above to frame rate instead is the REJECTED
|
|
// alternative: its body costs a bridge read plus a bank parse, and its two tick-counted banners
|
|
// (the bake message, the drop hint) are calibrated in ticks, so they would silently shorten by
|
|
// the same factor. 16 ms is 60 FPS; what a frame costs is the meter's bar field, not the client
|
|
// area — the whole-client invalidate is what made this rate unaffordable before.
|
|
constexpr UINT_PTR kMeterTimerId = 2;
|
|
constexpr UINT kMeterTimerIntervalMs = 16;
|
|
} // namespace
|
|
#endif
|
|
|
|
tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) {
|
|
#ifdef _WIN32
|
|
if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue;
|
|
#endif
|
|
return kResultFalse;
|
|
}
|
|
|
|
tresult PLUGIN_API ReaSamplerEditor::canResize() {
|
|
return kResultTrue;
|
|
}
|
|
|
|
tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) {
|
|
// The floor is the default size (sample_bands): the face can be grown, never shrunk below
|
|
// what its band stack is laid out for. The host calls this before every resize; clamp the
|
|
// proposed rect in place and return kResultTrue so the host applies the adjusted rect
|
|
// rather than the raw user drag.
|
|
if (!rect) return kResultFalse;
|
|
if (rect->getWidth() < instrument::ui::kEditorMinWidth) {
|
|
rect->right = rect->left + instrument::ui::kEditorMinWidth;
|
|
}
|
|
if (rect->getHeight() < instrument::ui::kEditorMinHeight) {
|
|
rect->bottom = rect->top + instrument::ui::kEditorMinHeight;
|
|
}
|
|
return kResultTrue;
|
|
}
|
|
|
|
#ifdef _WIN32
|
|
|
|
void ReaSamplerEditor::invalidate() {
|
|
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
|
|
}
|
|
|
|
void ReaSamplerEditor::invalidateMeter() {
|
|
if (!childHwnd_) return;
|
|
// The meter is covered (Browse, the curve popup) or has nothing to draw (empty state) —
|
|
// ballistics still advance in onMeterTimer, but nothing on screen changed, so invalidating
|
|
// anything here would only buy a whole-client repaint of chrome/waveform/deck the meter
|
|
// never touches. Distinct from "bounds not resolved yet" below: this is a fact about what
|
|
// the face is showing, not about whether meterRects_ happens to be populated.
|
|
const bool meterOnScreen =
|
|
view_ == View::kSample && curvePopup_ == CurveTarget::kNone && !selectedId_.empty();
|
|
if (!meterOnScreen) return;
|
|
|
|
const Rect& f = meterRects_.field;
|
|
if (f.empty()) {
|
|
// On screen, but no full paint has resolved its bounds yet (first paint, or a resize
|
|
// just dropped the cache) — the whole-client fallback is cheap here because the window
|
|
// is already fully invalid from the resize/creation that caused this.
|
|
invalidate();
|
|
return;
|
|
}
|
|
RECT r{f.x, f.y, f.right(), f.bottom()};
|
|
InvalidateRect(childHwnd_, &r, FALSE);
|
|
}
|
|
|
|
void ReaSamplerEditor::attachedToParent() {
|
|
HWND parent = static_cast<HWND>(systemWindow);
|
|
if (!parent) return;
|
|
|
|
HINSTANCE hInst =
|
|
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
|
|
if (!hInst) hInst = GetModuleHandle(nullptr);
|
|
|
|
static bool classRegistered = false;
|
|
if (!classRegistered) {
|
|
WNDCLASSW wc{};
|
|
wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
|
|
wc.hInstance = hInst;
|
|
wc.lpszClassName = kChildClassName;
|
|
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
|
// CS_DBLCLKS is what makes WM_LBUTTONDBLCLK arrive at all. It also REPLACES the second
|
|
// WM_LBUTTONDOWN of a double-click, so every surface that counted two downs (Browse's
|
|
// load accelerator) depends on the DBLCLK handler falling through to onMouseDown.
|
|
wc.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
|
|
RegisterClassW(&wc);
|
|
classRegistered = true;
|
|
}
|
|
|
|
// Create the kit's cached AA fonts before the first paint. 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.
|
|
kitFontsInit();
|
|
|
|
refreshFromBank();
|
|
|
|
const ViewRect& r = getRect();
|
|
childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
|
|
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
|
|
if (childHwnd_) {
|
|
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
|
|
// Accept OS file drops on the editor window (WM_DROPFILES). The drop is not ingested
|
|
// here (the relay is degraded — see onFilesDropped); accepting it lets us show the
|
|
// "drop on the panel" affordance instead of the OS bouncing the drop silently.
|
|
DragAcceptFiles(childHwnd_, TRUE);
|
|
// Start the change-detection poll (UI thread). Tied to the child window's lifetime —
|
|
// created here, killed in removedFromParent — so an instance whose editor is closed
|
|
// does not poll.
|
|
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
|
|
// Bound to the same window for the same reason: an instance with no editor open runs
|
|
// neither clock, and the meter's accumulators simply pile up until one opens.
|
|
SetTimer(childHwnd_, kMeterTimerId, kMeterTimerIntervalMs, nullptr);
|
|
// Poll once immediately so a pending assignment (an ingest fired while this editor was
|
|
// closed) or a bank change applies the instant the editor opens, rather than waiting up
|
|
// to one timer interval. refreshFromBank above already primed the view; this folds in
|
|
// any pending assign/generation so the just-opened editor shows the assigned capture.
|
|
onSyncTimer();
|
|
}
|
|
}
|
|
|
|
void ReaSamplerEditor::removedFromParent() {
|
|
// The processor outlives this view, so a bracket left open here would leave the host holding
|
|
// an edit forever and every later internal write to that parameter would emit a bare
|
|
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
|
|
// delivering WM_CAPTURECHANGED before the window goes away.
|
|
if (processor_) processor_->endParamGesture();
|
|
if (childHwnd_) {
|
|
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
|
KillTimer(childHwnd_, kMeterTimerId);
|
|
DestroyWindow(childHwnd_);
|
|
childHwnd_ = nullptr;
|
|
}
|
|
releaseBackBuffer(); // a client-area bitmap outlives nothing here
|
|
// Unreachable today (WM_PAINT outranks WM_TIMER, so a reopen's first paint resolves this
|
|
// before any meter tick can read it stale) — cleared anyway so the invariant is structural,
|
|
// not a timing accident, the same reason onSize clears it on resize.
|
|
meterRects_ = {};
|
|
}
|
|
|
|
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
|
tresult res = CPluginView::onSize(newSize);
|
|
if (childHwnd_ && newSize) {
|
|
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
|
|
thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them
|
|
ensureBackBuffer(newSize->getWidth(), newSize->getHeight());
|
|
// The cached meter rects name the OLD layout; drop them so the meter tick takes the
|
|
// whole-client path until the resize's own full paint resolves them again.
|
|
meterRects_ = {};
|
|
}
|
|
return res;
|
|
}
|
|
|
|
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|
LPARAM lParam) {
|
|
auto* self =
|
|
reinterpret_cast<ReaSamplerEditor*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));
|
|
switch (msg) {
|
|
case WM_PAINT: {
|
|
PAINTSTRUCT ps{};
|
|
HDC hdc = BeginPaint(hwnd, &ps);
|
|
if (self) self->paint(hdc, ps.rcPaint);
|
|
EndPaint(hwnd, &ps);
|
|
return 0;
|
|
}
|
|
case WM_LBUTTONDOWN:
|
|
if (self) {
|
|
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
|
|
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box
|
|
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
|
}
|
|
return 0;
|
|
case WM_LBUTTONDBLCLK:
|
|
if (self) {
|
|
SetCapture(hwnd);
|
|
SetFocus(hwnd);
|
|
const int mx = GET_X_LPARAM(lParam);
|
|
const int my = GET_Y_LPARAM(lParam);
|
|
// Knob reset first; anything it does not claim is the second click of the pair
|
|
// this message stands in for (see the class-style note above).
|
|
if (!self->onMouseDoubleClick(mx, my)) self->onMouseDown(mx, my);
|
|
}
|
|
return 0;
|
|
case WM_MOUSEMOVE:
|
|
if (self) {
|
|
const int mx = GET_X_LPARAM(lParam);
|
|
const int my = GET_Y_LPARAM(lParam);
|
|
// Hover feedback: 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:
|
|
// Browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
|
|
if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam));
|
|
return 0;
|
|
case WM_CHAR:
|
|
// Type-to-filter search keystrokes (only acted on when the search box is focused).
|
|
if (self) self->onSearchChar(static_cast<unsigned int>(wParam));
|
|
return 0;
|
|
case WM_GETDLGCODE:
|
|
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (search).
|
|
return DLGC_WANTCHARS | DLGC_WANTARROWS;
|
|
case WM_LBUTTONUP:
|
|
if (self) {
|
|
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
|
ReleaseCapture();
|
|
}
|
|
return 0;
|
|
case WM_RBUTTONDOWN:
|
|
// Right-click — the curve popup's primary node-delete affordance. Routed
|
|
// explicitly (the child wndproc historically handled only left-button).
|
|
if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
|
return 0;
|
|
case WM_RBUTTONDBLCLK:
|
|
// CS_DBLCLKS replaces the second RIGHT-button down too, so the spline surfaces'
|
|
// repeat-delete needs this peer or a fast double right-click drops one delete.
|
|
if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
|
return 0;
|
|
case WM_RBUTTONUP:
|
|
return 0; // claimed so the pair never reaches DefWindowProc (no context menu)
|
|
case WM_CAPTURECHANGED:
|
|
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore params_ to its
|
|
// pre-grab snapshot so the in-flight drag mutation is rolled back, then reset
|
|
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
|
|
// Mirror of the panel shell's WM_CAPTURECHANGED handler (panel_window.cpp).
|
|
if (self) {
|
|
// A held preview note must be released here too (peer of WM_LBUTTONUP) — capture
|
|
// loss otherwise leaves the momentary-key voice hung with no note-off.
|
|
if (self->previewingNote_ >= 0) {
|
|
if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_);
|
|
self->previewingNote_ = -1;
|
|
self->invalidate();
|
|
}
|
|
if (self->drag_ != DragKind::kNone) {
|
|
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
|
|
// voice count / master gain) mutate no params_ field, so dragStartParams_
|
|
// is not a rollback target for them — reset drag state only. Every
|
|
// params_-editing drag restores the pre-grab snapshot. Voice count and
|
|
// master gain are pre-existing exceptions to that: both write straight
|
|
// through on every move (editor voiceCount_ / processor masterGain_)
|
|
// rather than through params_, so an abandoned drag leaves them at the
|
|
// abandoned value indefinitely instead of rolling back.
|
|
const bool transient = self->drag_ == DragKind::kScrollThumb ||
|
|
(self->drag_ == DragKind::kDeckKnob &&
|
|
ReaSamplerEditor::deckKnobIsProcessorSide(self->dragParamId_));
|
|
if (!transient) {
|
|
self->params_ = self->dragStartParams_;
|
|
// A live drag already reached the voices AND the processor's own
|
|
// parameter set on every move, so restoring params_ alone would leave
|
|
// the face painting one value while the audio plays — and getState
|
|
// persists — the abandoned one. Roll back through the same tier the
|
|
// drag used.
|
|
if (self->dragCommitsLive(self->drag_, self->dragParamId_)) {
|
|
self->commitLive();
|
|
}
|
|
}
|
|
// Peer of onMouseUp's bracket close, and after the rollback for the same
|
|
// reason: the rollback's own performEdit belongs inside the bracket the grab
|
|
// opened, and an abandoned drag must not leave the host's edit open.
|
|
if (self->processor_) self->processor_->endParamGesture();
|
|
self->drag_ = DragKind::kNone;
|
|
self->dragParamId_ = -1;
|
|
self->dragInnerCellId_ = -1; // inner-dial drag state (peer reset)
|
|
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
|
|
// No cursor position is available here to re-resolve hover (unlike
|
|
// onMouseUp's release coordinates), so clear rather than leave it naming
|
|
// wherever the drag started.
|
|
self->hover_ = HoverTarget{};
|
|
self->invalidate();
|
|
}
|
|
}
|
|
return 0;
|
|
case WM_DROPFILES: {
|
|
// Count the dropped files and flash the affordance. We do not read/ingest the paths
|
|
// (the instrument never ingests — the relay to the extension is unshipped);
|
|
// DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
|
|
HDROP drop = reinterpret_cast<HDROP>(wParam);
|
|
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
|
|
DragFinish(drop);
|
|
if (self) self->onFilesDropped(static_cast<int>(count));
|
|
return 0;
|
|
}
|
|
case WM_TIMER:
|
|
if (self) {
|
|
if (wParam == kSyncTimerId) self->onSyncTimer();
|
|
else if (wParam == kMeterTimerId) self->onMeterTimer();
|
|
}
|
|
return 0;
|
|
case WM_ERASEBKGND:
|
|
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
|
|
default:
|
|
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
|
}
|
|
}
|
|
|
|
#else // non-Windows: not a build target, but keep the TU compilable.
|
|
|
|
void ReaSamplerEditor::attachedToParent() {}
|
|
void ReaSamplerEditor::removedFromParent() {}
|
|
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
|
return CPluginView::onSize(newSize);
|
|
}
|
|
|
|
#endif // _WIN32
|
|
|
|
} // namespace reasampler::vst
|