// bank_panel.cpp — REAPER-facing docked grid (M5 Wave A/B + Phase B4). See // bank_panel.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are // extern (CLAUDE.md §contract). // // What this file owns (all REAPER/SWELL/LICE-bound, hence DAW-verified, not unit // tested): // * a SWELL dialog (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via // DockWindowRemove; toggled open/closed. // * WM_PAINT: a VERTICAL SPLIT (Phase B4) — the pool grid region on top, a // LICE-drawn named-banks tab-page region below (one tab per named bank, an // overflow/scroll strip), and two full-height toggles that collapse the split. // Each region reuses the M5 grid render loop (waveform thumbnails / empty state). // * per-sample PCM read via PCM_source fed to peaks::computeEnvelope, one bin per // drawn pixel column; drawWaveform's gap-free render comes from peaks::columnMinMax. // * an in-memory thumbnail cache keyed by (sample id, draw width, bank generation). // * id-keyed bank management (create / rename / delete / evacuate / activate) and // sample move/copy — driven from a tab context menu and a drag — against the B1 // BankBook model on g_session.book(), persisted via g_session.saveToActiveProject(). // // READ-ONLY of the TIMELINE (load-bearing principle): this panel never inserts into // the arrange. It DOES mutate the bank BOOK (create/rename/move/etc.) — that is the // whole point of B4 — but only the index/model + ext-state, never the arrange, never // a sample file on disk (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank). // // THE PURE SEAMS: grid tiling / hit-test / selection math live in bank_grid; the // mode-switch geometry in mode_switch; the named-banks TAB-STRIP layout, overflow/ // scroll, and hit-test in tab_strip. All three are unit-tested outside the DAW; only // draw + input routing + the model calls live here. // // REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural // mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, // so a BankIndex& / Bank* must NEVER be cached across one. Every handler below // resolves fresh AFTER any mutation and passes bank IDS (not references) into the // model ops. #include "bank_panel.h" #include #include // std::abs (drag threshold) #include #include #include #include #include #include #include "action_bar.h" // pure TASK-GROUPED action-bar layout + hit-test (L2) #include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) #include "drag_out.h" // pure gesture-boundary decision + path-list assembly (M11) #include "drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) #include "app_version.h" // channelCommandId — compose the named-command lookup string (M11) #include "bank_book.h" #include "bank_grid.h" #include "bank_model.h" #include "card_drag.h" // L7 pure gesture precedence + sparse slot layout/hit-test #include "card_meta.h" // L7 decorative overlay formatters: bars.beats + s.ms (pure) #include "capture_paths.h" #include "component_geometry.h" // KitBox — the kit text()'s draw box (L1) #include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1) #include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4) #include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) #include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest #include "instrument_drop.h" // pure buildInstrumentDropPreset — the .vstpreset payload (S17) #include "instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17) #include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) #include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) #include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) #include "mode_switch.h" #include "overflow_menu.h" // top-toolbar More-button geometry + reserve (pure, L5) #include "peaks.h" #include "persist.h" #include "prune_button.h" // footer prune-button layout + hit-test (pure, R3) #include "tooltip.h" // tooltip placement + prefix-strip (pure, L5) #include "render_settings.h" // captureActionTable — the table-driven button rows (M11) #include "tab_strip.h" #include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) #include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) #include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires #include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2) // SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); // on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32). #ifdef _WIN32 #include #include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) #include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest #else #include #endif #include "wdltypes.h" #include "swell/swell.h" #include "lice/lice.h" #include "resource.h" #include "reaper_plugin.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_DockWindowAddEx #define REAPERAPI_WANT_DockWindowActivate #define REAPERAPI_WANT_DockWindowRemove #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_MarkProjectDirty // mark dirty when the tail toggle changes (saves with the project) #define REAPERAPI_WANT_GetMainHwnd #define REAPERAPI_WANT_PCM_Source_CreateFromFile #define REAPERAPI_WANT_PCM_Source_Destroy // New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane // state to classify an item's lane as managed vs manual. #define REAPERAPI_WANT_CountTracks #define REAPERAPI_WANT_GetTrack #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_GetTrackMediaItem // Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h): // PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the // STOCK symbols (not SWS-only) — see the audition section below. #define REAPERAPI_WANT_PlayPreview #define REAPERAPI_WANT_StopPreview #define REAPERAPI_WANT_GetUserInputs #define REAPERAPI_WANT_ShowMessageBox #define REAPERAPI_WANT_Main_OnCommand // fire the prune action by command id (R3 button) #define REAPERAPI_WANT_genGuid #define REAPERAPI_WANT_guidToString // Action-trigger buttons (M11): resolve each button's command id at runtime from the // composed named-command string, fire it through the existing action contract, and read // its current key binding for the reminder label. All main-section (SectionFromUniqueID(0)). #define REAPERAPI_WANT_NamedCommandLookup #define REAPERAPI_WANT_Main_OnCommand #define REAPERAPI_WANT_kbd_getTextFromCmd #define REAPERAPI_WANT_SectionFromUniqueID #include "reaper_plugin_functions.h" // main.cpp owns the module instance handle and REAPER's dispatch struct. extern REAPER_PLUGIN_HINSTANCE g_hInst; extern reaper_plugin_info_t* g_rec; namespace reasampler { namespace { namespace fs = std::filesystem; // --- Layout constants --------------------------------------------------------- // // L2: every panel COLOR now comes from the pure `theme` module by ROLE (drawn through the L1 // kit — fillSurface / drawButton / kit text). The former flat LICE_RGBA / RGB palette blocks // are retired; only the pixel LAYOUT metrics (band heights, grid/tab specs, insets) live here. const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) // --- Footer (Phase L, L4) ----------------------------------------------------- // The footer now carries a task-cluster of small persistent controls: the narrowed // [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 — // a real kit button, no longer a click-zone), and the set-apart Prune button at the // right. Taller than the L2 footer to host the toggle segments + button chrome cleanly. // Layout is the pure footer_bar (left group) + prune_button (right); this is the band height. constexpr int kFooterHeight = 30; // --- Toolbars (Phase L, L4) --------------------------------------------------- // TWO task-grouped toolbars, both drawn through the pure action_bar module: // * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of // the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there. // * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above // the footer (L4 §2). This is the L2 action-bar band, repurposed. // The kBarSpec metrics the bars consume live near the draw below; only heights live here. constexpr int kTopToolbarHeight = 28; // single-row label face (L6: keybinding sub-row removed) constexpr int kBottomToolbarHeight = 28; // same shape — both bars consistent // --- Tooltip (Phase L, L5) ---------------------------------------------------- // The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the // platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the // OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font // is AA and proportional, so the width is estimated from a per-char average (the tooltip box is // generous — a slight over/under-estimate only pads the box, never clips the text). constexpr unsigned int kTooltipDelayMs = 500; constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous) constexpr int kTooltipTextH = 14; // approx line height at Font::Label // --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) ------- // // The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) | // split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer // (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool // region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER // band: a title, the active-bank readout, and a full-height toggle button. The named-banks // region's header ALSO hosts the LICE tab strip and a "+" create button. constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band constexpr int kTabStripHeight = 26; // the named-banks tab strip band constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button constexpr int kCreateBtnWidth = 22; // the "+" create-bank button // Tab strip metrics (the pure tab_strip owns the math; these are its inputs). const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; // --- Panel state -------------------------------------------------------------- struct CachedThumbnail { Envelope envelope; int width = 0; }; // Which of the two split regions currently owns the selection / receives keyboard // input. The move/copy source is the focused region's displayed bank. enum class Region { Pool, Banks }; // What a drag is dropping onto, resolved live under the pointer during a drag. // BanksRegion fires when the pointer is anywhere in the named-banks grid that is NOT // on a specific tab (tab takes precedence — more specific wins). The resolved bank is // always shownBankId. enum class DropKind { None, PoolRegion, Tab, BanksRegion }; // --- Hover model (Phase L, L2) ------------------------------------------------ // // The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its // hover state on that element only (the "hover on every interactive element" + "sub-frame // feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep // of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None // rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe. // `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A. enum class HoverKind { None, TopBarButton, // a button in the TOP toolbar (index = flat action index into topBarRows) BottomBarButton, // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows) MoreButton, // the TOP toolbar's far-right "⋯" overflow-menu button (L5) PruneButton, FullHtPool, // pool region full-height toggle FullHtBanks, // banks region full-height toggle CreateBank, // the "+" create-bank button Tab, // a named-bank tab (index = tab ordinal) TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone) ModeSegment, // a footer mode-toggle segment (index = segment ordinal) }; struct Hover { HoverKind kind = HoverKind::None; int index = -1; bool operator==(const Hover& o) const { return kind == o.kind && index == o.index; } bool operator!=(const Hover& o) const { return !(*this == o); } }; // The kit interaction state for an interactive element: Hover when this (kind,index) is the // live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g. // an active tab draws Active regardless of hover); this is the base rest/hover resolver. InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) { return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover : InteractionState::Rest; } struct PanelState { ReaSamplerSession* session = nullptr; HWND hwnd = nullptr; bool open = false; std::string bankFingerprint; std::uint64_t generation = 0; std::unordered_map cache; // --- Selection (per focused region) --------------------------------------- // One live selection, scoped to `focusedRegion`. Switching regions moves the // selection with the focus (a click in the other region reseeds it there). Selection selection; int selItemCount = 0; Region focusedRegion = Region::Pool; // --- Hover (Phase L, L2) -------------------------------------------------- // The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its // hover state). Repaint fires only when this changes (sub-frame, no per-move jank). Hover hovered; // --- Tooltip (Phase L, L5) ------------------------------------------------ // A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a // TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel // draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the // GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover // change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly // once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the // hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event. unsigned int hoverSinceTick = 0; bool tooltipShown = false; // --- Vertical-split state ------------------------------------------------- BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; // The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT // from the active/capture-target bank (book().activeBankId()). Empty when there // are no named banks. Reconciled each fingerprint pass so it always names a live // named bank (or is empty). std::string shownBankId; // Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame. int tabScroll = 0; // --- Drag (sample move between regions/onto a tab) ------------------------ // A drag begins only after the pointer moves past a threshold from a press that // landed on a SELECTED grid cell — this is how it is disambiguated from the M5 // multi-select drag (which begins immediately on any grid press). See onLBtnDown/ // onMouseMove. dragging is true once the threshold is crossed. bool dragArmed = false; // pressed on a selected cell; watching for threshold bool dragging = false; // threshold crossed; a move-drag is in progress int dragStartX = 0, dragStartY = 0; Region dragSourceRegion = Region::Pool; std::string dragSourceBankId; // the bank the dragged samples come from std::vector dragSampleIds;// snapshot of the selection at drag start std::string dragPrimaryId; // the single card grabbed (the focus) — the L7 // reorder/replace subject (see onLBtnUp dispatch) DropKind dropKind = DropKind::None; // live drop target under the pointer std::string dropBankId; // destination bank id when dropKind==Tab // --- L7 in-grid reorder/replace drag -------------------------------------- // The live card gesture resolved by the pure card_drag::decideCardGesture each mouse- // move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the // pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1 // otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue // signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel. CardGesture cardGesture = CardGesture::None; int dragTargetSlot = -1; // --- S17 drop-and-load (InstrumentDrop) ----------------------------------- // While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an // InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX // hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the // dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last // resolved FX-hotspot track (null when the pointer is not over an FX button) — read on // release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched. MediaTrack* instrumentDropTrack = nullptr; // --- Tail-mode toggle ----------------------------------------------------- // The authoritative tail setting now lives in ReaSamplerSession (session->tail()), // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, // restores it on project load). The panel reads it for drawing and mutates it via // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the // project dirty so the choice saves. bankPanelTailSetting is the read seam for the // capture actions. Held here only through the session pointer above. // --- Audition preview ----------------------------------------------------- preview_register_t preview{}; PCM_source* previewSrc = nullptr; bool previewActive = false; bool previewInited = false; // guards double init / deinit // --- New-content detection (D2 Wave 2) ------------------------------------ // // Each timer tick diffs the live track+item GUID set against the previous tick to // auto-tag content created SINCE the last tick into the then-active mode. The // baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its // first observe()) so pre-existing content is never mass-tagged (it stays Arrange). // // Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a // pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact // tick persist restores a project's membership + active mode (the same tick it // reapplies the active mode); that sets reloadPending so the NEXT detect tick this // same tick re-baselines against the fully-loaded set and reports nothing new. This // replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than // persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto // a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then // diffed against the previous project's stale baseline and were mass-tagged into the // active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the // two identity checks agree by construction. // // Lives for the extension's lifetime alongside the session, independent of panel // open/close — detection must run whether or not the dock is visible (content is // created in the arrange, not the panel). GuidBaseline contentBaseline; bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick }; PanelState g_panel; void stopAudition(); // --- Current-project directory (mirrors persist.cpp's derivation) ------------- std::string currentProjectDir() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); std::string rpp(buf.data()); if (rpp.empty()) return {}; return normalizeSlashes(fs::path(rpp).parent_path().string()); } // --- Book / bank accessors ---------------------------------------------------- BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } // The BankIndex a region currently displays. Pool region -> the pool; banks region -> // the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved // FRESH every call (never cached across a mutation). const BankIndex* indexForRegion(Region r) { BankBook* b = book(); if (!b) return nullptr; if (r == Region::Pool) return &b->pool().index; if (g_panel.shownBankId.empty()) return nullptr; return b->index(g_panel.shownBankId); } // The bank id a region displays (pool id, or the shown tab's id; "" when none). std::string bankIdForRegion(Region r) { if (r == Region::Pool) return std::string(kPoolBankId); return g_panel.shownBankId; } // The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh. std::vector namedBanks() { std::vector out; BankBook* b = book(); if (!b) return out; for (const Bank& bk : b->banks()) if (!bk.isPool()) out.push_back(&bk); return out; } // --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) -- Envelope computeThumbnail(const std::string& absPath, int width) { if (width <= 0 || absPath.empty()) return {}; PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str()); if (!src) return {}; const int nch = src->GetNumChannels(); const double srate = src->GetSampleRate(); const double lengthSec = src->GetLength(); if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) { PCM_Source_Destroy(src); return {}; } std::int64_t totalFrames = static_cast(lengthSec * srate); if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; } int frames = totalFrames > kMaxThumbnailFrames ? kMaxThumbnailFrames : static_cast(totalFrames); std::vector buf(static_cast(frames) * nch, 0.0); PCM_source_transfer_t block{}; block.time_s = 0.0; block.samplerate = srate; block.nch = nch; block.length = frames; block.samples = buf.data(); block.samples_out = 0; src->GetSamples(&block); PCM_Source_Destroy(src); const int got = block.samples_out; if (got <= 0) return {}; const std::size_t sampleCount = static_cast(got) * nch; std::vector pcm(sampleCount); for (std::size_t i = 0; i < sampleCount; ++i) pcm[i] = static_cast(buf[i]); // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with // trailing empty {0,0} bins, which would render a very short sample as a comb of // spikes over flat gaps. const int binCount = width < got ? width : got; return computeEnvelope(pcm, static_cast(nch), static_cast(got), static_cast(binCount)); } const Envelope& thumbnailFor(const Sample& sample, int width, const std::string& projectDir) { ThumbnailKey key{sample.id, width, g_panel.generation}; const std::string ks = thumbnailKeyString(key); auto it = g_panel.cache.find(ks); if (it != g_panel.cache.end()) return it->second.envelope; const std::string abs = resolveBankFile(projectDir, sample.relativePath); CachedThumbnail thumb; thumb.width = width; thumb.envelope = computeThumbnail(abs, width); auto ins = g_panel.cache.emplace(ks, std::move(thumb)); return ins.first->second.envelope; } // --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) --------- // Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT // (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT // (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's // Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical // read-out (unstamped meter / unknown tempo) simply omits the bottom-left string. void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) { MusicalLength ml; ml.lengthSeconds = s.lengthSeconds; ml.tempoBpm = s.captureTempo; ml.timeSigNum = s.captureTimeSigNum; ml.timeSigDenom = s.captureTimeSigDenom; const std::string bars = formatBarsBeats(ml); // "" when unstamped/no-tempo const std::string secs = formatSecondsMs(s.lengthSeconds); // A short strip along the card's bottom edge. Left/right halves; text/dim so the // waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right // (tabular numbers that must not jitter). const int stripH = 12; const int pad = 3; const int y = rect.y + rect.height - stripH; if (!bars.empty()) { const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH}; text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left); } const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH}; text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right); } void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, bool selected, bool focused, bool hovered, const Sample* sample) { // Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL // cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is // marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill- // state change orthogonal to that border, so a hovered selected card still reads selected. const KitBox cell{rect.x, rect.y, rect.width, rect.height}; const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest; fillSurface(bmp, cell, Role::BgCell, state); // Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else // hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card // reads BOTH — the purple outer border + the inner focus ring — kept visually separate. const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline); LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0); if (focused) { const LICE_pixel ring = toLice(roleColor(Role::TextPrimary)); LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0); } // Waveform plot through the kit's shared primitive (FA3): the SAME per-pixel-column // min/max envelope draw the VST editor hero + browser cards use — one algorithm, one // look, everywhere. The oversampled env (see drawRegionGrid's binWidth) collapses per // column via peaks::columnMinMax inside the kit; an empty env draws just the midline. drawWaveform(bmp, cell, env); // L7 decorative metadata overlay, drawn last so it sits over the waveform. if (sample) drawCardMeta(bmp, rect, *sample); } // --- Kit draw adapters (Phase L) ---------------------------------------------- // // All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText // (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces // via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based // geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for // the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle // (kitFontsInit/Shutdown, wired at panel open/close below). KitBox toKitBox(const RECT& r) { return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top}; } // KitColor -> LICE_pixel: all sites use the kit's toLice() from draw_kit.h — the single // conversion boundary the kit enforces. No local alias needed. // L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit // Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for // region headings). Takes a KitBox directly (the pure geometry the L2 modules return). void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt, Font font, Role role, Align align) { text(bmp, box, txt, font, role, align); } // --- Mode toggle (D5; relocated to the footer at L4) -------------------------- int modeCount() { if (!g_panel.session) return 0; return static_cast(g_panel.session->view().modes().size()); } // --- Top toolbar band (L4; L5 overflow-menu reserve) -------------------------- // // The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate // (height 0) when the client is too short to host it above the split body. The WHOLE band // (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent // buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run // under the menu button (L5 refinement 1). // The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its // geometry + the reserve the action_bar leaves for it. const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6, /*verticalInset=*/3, /*minLeftInset=*/40}; ActionBarRect topToolbarRect(int w) { ActionBarRect s; s.x = 0; s.y = 0; s.width = w; s.height = kTopToolbarHeight; return s; } // The band the More button occupies (the whole top toolbar band as a MenuBarRect). MenuBarRect topMenuBarRect(int w) { const ActionBarRect bar = topToolbarRect(w); return MenuBarRect{bar.x, bar.y, bar.width, bar.height}; } // The More button's rect (right-anchored in the top band). Empty when the band is too narrow // to place it clear of its left inset — the three variants stay reachable via their bindable // commands (graceful suppression). MenuButtonRect topMenuButtonRect(int w) { return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec); } // The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the // far-right More button, so the frequent buttons never overlap it. When the More button is // suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a // degenerate band), which keeps draw and hit-test consistent whether or not the button shows. ActionBarRect topToolbarActionRect(int w) { ActionBarRect bar = topToolbarRect(w); const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec); bar.width -= reserve; if (bar.width < 0) bar.width = 0; return bar; } // --- Footer (L4) -------------------------------------------------------------- RECT panelFooter(int w, int h) { RECT rc{}; rc.left = 0; rc.right = w; rc.top = h - kFooterHeight; rc.bottom = h; // Keep the footer below the top toolbar; if the client is too short, collapse it. if (rc.top < kTopToolbarHeight) rc.top = rc.bottom; return rc; } // The session's live tail setting (default None / 2 s when no session). Single read // point so draw, wheel-adjust, and the capture read seam all agree on the source. TailSetting currentTail() { return g_panel.session ? g_panel.session->tail() : TailSetting{}; } // The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client // size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate. FooterBarLayout footerBarLayoutFor(int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return FooterBarLayout{}; const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; return computeFooterBar(footer, FooterBarSpec{}); } // The per-mode membership count that travels with the toggle (L4 §3): the number of leaves // tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no // session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.) // A display-only tally over the model's public membership map — no model semantics duplicated. int activeModeMemberCount() { if (!g_panel.session) return 0; const ViewModeModel& view = g_panel.session->view(); const std::string& active = view.activeModeId(); if (active.empty()) return 0; int n = 0; for (const auto& [guid, m] : view.membership().all()) if (m.modeIds.count(active) != 0) ++n; return n; } // The prune button's rect within the footer, derived from the client size. SINGLE source // of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate // or too narrow to place the button clear of the footer-left group / version readout — the // action stays reachable via its bindable command, so a suppressed button is graceful. Kept // set apart at the RIGHT (footer_bar reserves the matching space at its right so the two // groups never overlap). See prune_button.h §Placement contract. ButtonRect pruneButtonRectFor(int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; return computePruneButton(footer, PruneButtonSpec{}); } // Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design] // toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and // the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button // set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it. void drawFooter(LICE_IBitmap* bmp, int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return; // Footer band + hairline top divider (the base persistent-controls strip). fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel, InteractionState::Rest); LICE_Line(bmp, f.left, f.top, f.right, f.top, toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); const FooterBarLayout fb = footerBarLayoutFor(w, h); // [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box // (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The // active mode's segment carries the accent; others hover-or-rest bg/cell. if (!fb.toggle.empty() && g_panel.session) { const ViewModeModel& view = g_panel.session->view(); const std::vector& modes = view.modes().all(); const int n = static_cast(modes.size()); const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; const std::vector segs = computeSegmentRects(th, n); const std::string& activeId = view.activeModeId(); for (int i = 0; i < static_cast(segs.size()); ++i) { const SegmentRect& s = segs[static_cast(i)]; const Mode& mode = modes[static_cast(i)]; const bool active = mode.id == activeId; const InteractionState state = active ? InteractionState::Active : hoverState(g_panel.hovered, HoverKind::ModeSegment, i); fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state); LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, toLice(roleColor(Role::LineHairline)), 1.0f, 0); const Role tr = active ? Role::BgBase : Role::TextPrimary; kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(), Font::Label, tr, Align::Center); } } // Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count // travels with the toggle"). Passive text, not a control. if (!fb.count.empty()) { const int members = activeModeMemberCount(); const std::string countLabel = std::to_string(members) + (members == 1 ? " track" : " tracks"); kitText(bmp, KitBox{fb.count.x, fb.count.y, fb.count.width, fb.count.height}, countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center); } // Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the // tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel. if (!fb.tail.empty()) { const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1); const std::string label = tailToggleLabel(currentTail()); const KitButtonBox box{KitBox{fb.tail.x, fb.tail.y, fb.tail.width, fb.tail.height}}; drawButton(bmp, box, label.c_str(), state, /*warn=*/false); } // Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion() // renders the configured version string on stable and that string plus "-beta" on beta, // so a beta panel self-identifies. It sits inside the space footer_bar reserves at the // right (rightReserve) and clears the prune button (prune_button::rightInset). Dim, // passive identification (V3). kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top}, reasampler::appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right); // Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control), // honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign, // frequent) -> right (destructive, rare) per the L4 footer contract. const ButtonRect pb = pruneButtonRectFor(w, h); if (!pb.empty()) { const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1); const KitButtonBox box{KitBox{pb.x, pb.y, pb.width, pb.height}}; drawButton(bmp, box, "Prune", state, /*warn=*/true); } } // True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the // scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The // Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar). bool pointInFooter(int x, int y) { if (!g_panel.hwnd) return false; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top); return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; } // Commits the current tail setting to ext state and marks the active project dirty // so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only // path that calls SetProjExtState for the tail key — calling it here closes the gap // where toggle/scroll would dirty the project but the new value was never written. // On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h). // MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way. // NON-DESTRUCTIVE: touches nothing in the bank/arrange. void markTailDirty() { if (g_panel.session) g_panel.session->saveToActiveProject(); ReaProject* proj = EnumProjects(-1, nullptr, 0); if (proj) MarkProjectDirty(proj); } // === Task-grouped toolbars (Phase L, L2 + L4) ================================= // // L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin) // across TWO toolbars, BOTH drawn through the pure action_bar module: // * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the // two acts the tool exists for (L4 §1); // * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the // footer, in the space capture/placement vacated (L4 §2). // Each button is drawn with its action name (Font::Label) and live key binding on a Micro // sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects, // the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query // + the NamedCommandLookup/Main_OnCommand dispatch live here. // // Each button resolves its command id at RUNTIME from the composed named-command string // (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable // and beta and adds NO second registration. A cmd of 0 (action not registered on this channel) // draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the // SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts. // One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix // at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action // NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is // dropped at build), and the task cluster it belongs to. The order of a toolbar's row list IS // the flat action index the pure action_bar slots carry, so each list is built cluster-by-cluster // in its toolbar's cluster order. // // L5: the FACE stays short (shortLabel, sized to never overflow the button width); the FULL name // (fullName) is the hover tooltip content. fullName is sourced from the SAME phrase the action // was registered with (render_settings' descriptionPhrase for the capture scopes; the literal // registered phrase otherwise) so the tooltip matches the Actions-list entry exactly — the // "ReaSampler:" prefix is not stored here (the face/tooltip never show it, per L5 refinement 2). struct ActionBarRow { std::string suffix; std::string shortLabel; std::string fullName; ActionCluster cluster = ActionCluster::Capture; bool enabled = true; // L5: opposite-mode gate for the bottom-bar tag buttons; always true // for the top bar (its actions are unconditional triggers). }; // The TOP toolbar inventory (L6 refinement): the FREQUENT acts only — Capture (item / track) // then Re-capture (Maintenance, set between the two capture verbs and the placement verbs) then // Placement (insert / insert-conform). The FOUR RARE variants (Batch Items / Batch Razor / // Capture RT / Cancel RT) are ALL in the far-right "⋯" overflow menu (overflowMenuRows) — // same registered actions, same command-id contract, just a different home. Capture scopes come // from captureActionTable() (render_settings, pure); the rest are the registered M11/M10/M8 // commands. Built once per draw/click. Each row carries its full (prefix-stripped) action name // for the hover tooltip. std::vector topBarRows() { std::vector rows; // Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track"; // the tooltip carries the full descriptionPhrase the action was registered with. for (const CaptureActionDef& def : captureActionTable()) { std::string label = def.commandSuffix; if (label == "CAPTURE_ITEM") label = "Capture Item"; else if (label == "CAPTURE_TRACK") label = "Capture Track"; rows.push_back({def.commandSuffix, label, def.descriptionPhrase, ActionCluster::Capture, true}); } // Maintenance cluster — Re-capture from source (M10), placed BETWEEN the capture group and // the placement group so its position reads "refine the last capture before placing it". // Cancel RT lives in the overflow menu (both realtime verbs share that home — L6). rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", "re-capture from source", ActionCluster::Maintenance, true}); // Placement cluster — the second act (still a distinct on-demand act; no auto-insert). rows.push_back({"INSERT_SELECTED", "Insert", "insert selected sample at edit cursor", ActionCluster::Placement, true}); rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", "insert selected sample at edit cursor (conform to tempo)", ActionCluster::Placement, true}); return rows; } // The TOP-toolbar OVERFLOW menu inventory (L6): four items pulled off the visible bar into the // far-right "⋯" menu button's popup — the three rare batch/realtime capture variants plus // Cancel RT (both realtime verbs share the menu home). Each fires the SAME existing registered // command id via the SAME NamedCommandLookup/Main_OnCommand contract — no action changes. The // fullName is the popup entry text (the terse shortLabel is unused for menu items; the popup has // room for the full name). Batch entries first, then the two realtime verbs. std::vector overflowMenuRows() { return { {"CAPTURE_BATCH_ITEMS", "Batch Items", "batch capture selected items (one per item)", ActionCluster::Capture, true}, {"CAPTURE_BATCH_RAZOR", "Batch Razor", "batch capture razor areas (one per area)", ActionCluster::Capture, true}, {"CAPTURE_TRACK_REALTIME", "Capture RT", "capture selected track (realtime)", ActionCluster::Capture, true}, {"CANCEL_REALTIME_CAPTURE", "Cancel RT", "cancel realtime capture", ActionCluster::Maintenance, true}, }; } // The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for // "which mode is active"). Empty when no session (every button then falls to fail-open live). std::string activeModeIdOrEmpty() { if (!g_panel.session) return {}; return g_panel.session->view().activeModeId(); } // The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons // then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from // actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; // VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not // paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). // The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the // footer [Arrange|Design] toggle owns mode switching. // // OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode // (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from // the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw // Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the // Track button for a target share the target's enablement. std::vector bottomBarRows() { const std::string active = activeModeIdOrEmpty(); const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange); const bool designLive = tagButtonEnabled(active, TagTarget::Design); std::vector rows; // Tagging cluster — the four Item/Track x Arrange/Design tag buttons. rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange", "move selected items -> Arrange", ActionCluster::Tagging, arrangeLive}); rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design", "move selected items -> Design", ActionCluster::Tagging, designLive}); rows.push_back({"VIEW_TAG_ARRANGE", "Track: Arrange", "tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive}); rows.push_back({"VIEW_TAG_DESIGN", "Track: Design", "tag selected tracks -> Design", ActionCluster::Tagging, designLive}); // Switching cluster — Show Both, set apart (the only survivor of the old switching group). rows.push_back({"VIEW_SHOW_BOTH", "Show Both", "show both for selected tracks", ActionCluster::Switching, true}); return rows; } // The cluster button-count specs for a given row set, in the row list's cluster order (so the // pure action_bar's flat index lines up with the row list). Handles all five cluster kinds; // empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec // order follows each toolbar's fixed layout order (top: Capture, Maintenance, Placement — // Re-capture sits between the two capture verbs and the placement verbs; bottom: Tagging, // Switching). The bottom bar's Maintenance count is 0, so the order change is transparent there. std::vector actionBarClusters(const std::vector& rows) { int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0; for (const ActionBarRow& r : rows) { switch (r.cluster) { case ActionCluster::Capture: ++nCap; break; case ActionCluster::Placement: ++nPlace; break; case ActionCluster::Maintenance: ++nMaint; break; case ActionCluster::Tagging: ++nTag; break; case ActionCluster::Switching: ++nSwitch; break; } } return { {ActionCluster::Capture, nCap}, {ActionCluster::Maintenance, nMaint}, {ActionCluster::Placement, nPlace}, {ActionCluster::Tagging, nTag}, {ActionCluster::Switching, nSwitch}, }; } // The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared // by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5: // clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups. L6: // bindingHeight / minSplitHeight removed — buttons are single-row label-only faces now. const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24, /*sidePad=*/8, /*verticalInset=*/3}; // The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split // body). Degenerate (height 0) when the client is too short to host it above the footer. ActionBarRect bottomToolbarRect(int w, int h) { ActionBarRect s; const RECT footer = panelFooter(w, h); const int footerTop = (footer.top < footer.bottom) ? footer.top : h; s.x = 0; s.width = w; s.height = kBottomToolbarHeight; s.y = footerTop - kBottomToolbarHeight; // Keep the bar below the top toolbar; if the client is too short, collapse it. if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; } return s; } // Resolves a row's composed named command to its runtime command id (0 if not registered). // The named-command lookup string is "_" + the channel-qualified id (REAPER's convention). int resolveBarCommandId(const ActionBarRow& row) { if (!NamedCommandLookup) return 0; const std::string named = "_" + channelCommandId(row.suffix); return NamedCommandLookup(named.c_str()); } // The current key binding string for a command in the MAIN section, or "" (unbound / not // registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)). std::string barBindingText(int cmd) { if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) { const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); if (t) return std::string(t); } return {}; } // Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button // as a kit drawButton (rest/hover/disabled) with the action short label on the single-row face. // Overflow drops WHOLE trailing buttons (the pure layout returns only the buttons that fit), so // nothing is drawn clipped. `hoverKind` selects which HoverKind this bar's buttons use // (TopBarButton / BottomBarButton) so the two toolbars' hover states never cross. `topDivider` // draws a hairline at the band's top edge (the bottom toolbar's elevation over the split body); // the top toolbar draws it at its bottom edge instead. Key binding help is in the hover tooltip // (L6), not on the button face — the face shows only shortLabel. void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, const std::vector& rows, HoverKind hoverKind, bool topDivider) { if (bar.height <= 0 || bar.width <= 0) return; const KitBox band{bar.x, bar.y, bar.width, bar.height}; fillSurface(bmp, band, Role::BgPanel, InteractionState::Rest); const int dividerY = topDivider ? bar.y : bar.y + bar.height - 1; LICE_Line(bmp, bar.x, dividerY, bar.x + bar.width, dividerY, toLice(roleColor(Role::LineHairline)), 0.5f, 0, false); const std::vector clusters = actionBarClusters(rows); const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); for (const ActionBarSlot& s : slots) { if (s.index < 0 || s.index >= static_cast(rows.size())) continue; const ActionBarRow& row = rows[static_cast(s.index)]; const int cmd = resolveBarCommandId(row); // State: Disabled when the action is not registered on this channel OR the row is gated // off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); else Hover // when hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.) InteractionState state = InteractionState::Rest; if (cmd == 0 || !row.enabled) state = InteractionState::Disabled; else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index) state = InteractionState::Hover; // The button surface (drawButton draws the micro-gradient + rounded border + honors // the state). The label is drawn separately so the text role tracks the state correctly; // pass no label to drawButton. const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}}; drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); const Role textRole = (state == InteractionState::Disabled) ? Role::TextDim : Role::TextPrimary; const KitBox labelBox{s.labelX, s.labelY, s.labelW, s.labelH}; kitText(bmp, labelBox, row.shortLabel.c_str(), Font::Label, textRole, Align::Center); } } // The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test. int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows) { if (bar.height <= 0) return -1; return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec); } // Routes a click in a toolbar to the hit button's action, fired through the command-id contract // (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click // was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the // caller stops before grid handling. `rows` is the toolbar's inventory. bool handleToolbarClick(int x, int y, const ActionBarRect& bar, const std::vector& rows) { if (bar.height <= 0) return false; const int hit = toolbarHit(x, y, bar, rows); if (hit < 0) { // Inside the band but in a gap / overflow dead-zone: claim it so it never falls through // to the grid. Outside the band: not ours. return y >= bar.y && y < bar.y + bar.height && x >= bar.x && x < bar.x + bar.width; } const ActionBarRow& row = rows[static_cast(hit)]; // A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the // action and never falls through to the grid (a dead button reads as inert, not absent). if (!row.enabled) return true; const int cmd = resolveBarCommandId(row); if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); return true; } // --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) ------------------- // // The three rare capture variants live only in this popup. The button is drawn kit-style (rest/ // hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the // variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand // (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine // for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure. // Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow). void drawMoreButton(LICE_IBitmap* bmp, int w) { const MenuButtonRect mb = topMenuButtonRect(w); if (mb.empty()) return; const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1); const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}}; drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); // The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text // path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More". kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...", Font::Label, Role::TextPrimary, Align::Center); } // Opens the More popup: defined later (after the menuAppend/menuSeparator helpers), forward- // declared here so drawMoreButton's neighbours read together. The click site (handleClick) sits // after the definition, so no forward-declaration is strictly required — this documents intent. void showMoreMenu(); // --- Tooltip (L5 refinement 2) ------------------------------------------------ // // A custom hover-delay tooltip: the full, prefix-stripped action name of the hovered toolbar // button. Resolves the hovered element to its (anchor rect, text); returns false when the current // hover has no tooltip (grid / chrome / the More button — the More button's own popup is its // affordance). The tooltip DRAW is below; timing (kTooltipDelayMs) is applied by the caller. // The full (prefix-stripped) tooltip text for the currently hovered toolbar button, plus its // anchor rect. Returns false when the hover is not a tooltip-bearing toolbar button. bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah) { const Hover& hv = g_panel.hovered; std::vector rows; ActionBarRect bar{}; if (hv.kind == HoverKind::TopBarButton) { rows = topBarRows(); bar = topToolbarActionRect(w); } else if (hv.kind == HoverKind::BottomBarButton) { rows = bottomBarRows(); bar = bottomToolbarRect(w, h); } else { return false; } if (hv.index < 0 || hv.index >= static_cast(rows.size())) return false; // The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw + // hit-test use, so the anchor matches the drawn button exactly. const std::vector clusters = actionBarClusters(rows); const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); const ActionBarSlot* slot = nullptr; for (const ActionBarSlot& s : slots) if (s.index == hv.index) { slot = &s; break; } if (!slot) return false; // The full name is stored already prefix-free, but strip defensively in case a source ever // carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2). // L6: the keybinding sub-row was removed from the button face, so the tooltip now carries // both the name AND the binding (when bound) — e.g. "capture selected item — F5". When the // action is unbound the tooltip shows only the name (no "(unbound)" noise in the tooltip). const std::string phrase = stripActionPrefix(rows[static_cast(hv.index)].fullName, actionDisplayPrefix()); const int cmd = resolveBarCommandId(rows[static_cast(hv.index)]); const std::string binding = barBindingText(cmd); textOut = binding.empty() ? phrase : phrase + " \xe2\x80\x94 " + binding; // " — " (em dash, UTF-8) ax = slot->x; ay = slot->y; aw = slot->width; ah = slot->height; return true; } // Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current // hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the // paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the // anchor, flipping above near the bottom edge, clamped to the client). void drawTooltip(LICE_IBitmap* bmp, int w, int h) { if (!g_panel.tooltipShown) return; std::string txt; int ax = 0, ay = 0, aw = 0, ah = 0; if (!currentTooltip(w, h, txt, ax, ay, aw, ah) || txt.empty()) return; const int textW = static_cast(txt.size()) * kTooltipCharPx; const TooltipBox tb = computeTooltip(ax, ay, aw, ah, textW, kTooltipTextH, w, h, TooltipSpec{}); if (tb.empty()) return; // The tooltip surface: a raised bg/cell chip with a hairline border, then the AA text. const KitBox box{tb.x, tb.y, tb.width, tb.height}; fillSurface(bmp, box, Role::BgCell, InteractionState::Hover); LICE_DrawRect(bmp, tb.x, tb.y, tb.width, tb.height, toLice(roleColor(Role::LineHairline)), 1.0f, 0); kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center); } // --- Split geometry ----------------------------------------------------------- // // Every rect below is derived from the client size + fullHeight state, and BOTH paint // and hit-testing call these so they never drift. All are top-left origin. // The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the // top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a // short client, bottomToolbarRect returns its y at the footer top, so the body still ends there. RECT splitBody(int w, int h) { RECT rc{}; rc.left = 0; rc.right = w; rc.top = kTopToolbarHeight; const ActionBarRect bar = bottomToolbarRect(w, h); rc.bottom = bar.y; if (rc.bottom < rc.top) rc.bottom = rc.top; return rc; } // True when both regions are shown (the split is live). Otherwise one region fills // the body. bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; } bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; } // The pool region's rect (whole-region: header band + grid). Empty when hidden. RECT poolRegionRect(int w, int h) { const RECT body = splitBody(w, h); if (!poolShown()) return RECT{0, 0, 0, 0}; if (!banksShown()) return body; // pool full-height: the whole body // Split: pool gets the top half (minus the divider). RECT rc = body; rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; if (rc.bottom < rc.top) rc.bottom = rc.top; return rc; } // The named-banks region's rect (whole-region: header band + tab strip + grid). RECT banksRegionRect(int w, int h) { const RECT body = splitBody(w, h); if (!banksShown()) return RECT{0, 0, 0, 0}; if (!poolShown()) return body; // banks full-height: the whole body RECT rc = body; rc.top = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2 + kSplitDividerHeight; if (rc.top > rc.bottom) rc.top = rc.bottom; return rc; } // A region's header band (the top kRegionHeaderHeight of the region). RECT regionHeaderRect(const RECT& region) { RECT rc = region; rc.bottom = region.top + kRegionHeaderHeight; if (rc.bottom > region.bottom) rc.bottom = region.bottom; return rc; } // The named-banks region's tab strip (below its header band). TabStripRect banksTabStripRect(const RECT& region) { const RECT hdr = regionHeaderRect(region); TabStripRect s; s.x = region.left; s.y = hdr.bottom; s.width = region.right - region.left; s.height = kTabStripHeight; if (s.y + s.height > region.bottom) s.height = region.bottom - s.y; if (s.height < 0) s.height = 0; return s; } // A region's grid viewport (below the header band, and below the tab strip for the // banks region). This is where cells tile. RECT regionGridRect(const RECT& region, bool isBanks) { RECT rc = region; rc.top = region.top + kRegionHeaderHeight; if (isBanks) rc.top += kTabStripHeight; if (rc.top > rc.bottom) rc.top = rc.bottom; return rc; } // The full-height toggle button rect inside a region header (right-aligned). RECT fullHtBtnRect(const RECT& region) { const RECT hdr = regionHeaderRect(region); RECT rc = hdr; rc.right = hdr.right - 4; rc.left = rc.right - kFullHtBtnWidth; rc.top = hdr.top + 2; rc.bottom = hdr.bottom - 2; return rc; } // The "+" create-bank button rect inside the named-banks region header (left of the // full-height button). RECT createBtnRect(const RECT& region) { RECT ft = fullHtBtnRect(region); RECT rc = ft; rc.right = ft.left - 4; rc.left = rc.right - kCreateBtnWidth; return rc; } // --- L7 slot-order display bridge --------------------------------------------- // // L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted // SlotMap order (sparse, gap-preserving), NOT BankIndex insertion order. This one helper // is the single place that resolves a region's display, composed purely from bank_book's // slot order (orderedSampleIds) + card_drag's sparse slot rects (computeSlotRects) — the // shell adds no layout math of its own. // // TWO INDEX SPACES the whole panel must keep straight: // * SLOT — a display position 0..maxSlot; gaps are empty slots that draw as empty // cells and are valid drop targets. This is what pixels/hit-tests speak. // * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection / // applyClick / navigate reason in. Selection index i <-> orderedIds[i]. // Keyboard navigation therefore traverses ONLY occupied cells and SKIPS // gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here). // RegionDisplay carries both plus the translation between them, resolved FRESH each call // (never cached across a mutation, per the reference-invalidation guardrail). struct RegionDisplay { std::vector orderedIds; // occupied ids in slot order (selection space) std::vector slotRects; // one rect per slot 0..maxSlot, viewport coords const Bank* bank = nullptr; // The id occupying `slot`, or "" for an empty slot / out of range. std::string idAtSlot(int slot) const { return bank ? bank->slots.idAt(slot) : std::string{}; } // The slot a selection ordinal `sel` maps to, or -1. orderedIds[sel] -> its slot. int slotForSelection(int sel) const { if (sel < 0 || sel >= static_cast(orderedIds.size()) || !bank) return -1; return bank->slots.slotOf(orderedIds[static_cast(sel)]); } // The selection ordinal for `slot` (index of its occupant in orderedIds), or -1 when // the slot is empty. Inverse of slotForSelection. int selectionForSlot(int slot) const { const std::string id = idAtSlot(slot); if (id.empty()) return -1; for (std::size_t i = 0; i < orderedIds.size(); ++i) if (orderedIds[i] == id) return static_cast(i); return -1; } int occupiedCount() const { return static_cast(orderedIds.size()); } }; // Resolves a region's display for the currently-shown bank. Empty (no bank / no width) // yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live // membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete // order (trailing empties are trimmed by the model — maxSlot walks only live occupants). RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) { RegionDisplay d; BankBook* b = book(); if (!b) return d; const std::string bankId = bankIdForRegion(reg); if (bankId.empty()) return d; d.bank = b->bank(bankId); if (!d.bank) return d; d.orderedIds = b->orderedSampleIds(bankId); // occupied ids, slot order (reconciles) if (d.orderedIds.empty()) return d; const RECT grid = regionGridRect(region, isBanks); const int w = grid.right - grid.left; if (w <= 0) return d; d.slotRects = computeSlotRects(d.bank->slots.maxSlot(), w, kGrid); for (SlotCellRect& r : d.slotRects) { r.x += grid.left; r.y += grid.top; } return d; } // The FOCUSED region's display (the slot-order bridge for the region holding the live // selection). Mirrors columnsForRegion's client read. RegionDisplay focusedDisplay() { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; const bool isBanks = g_panel.focusedRegion == Region::Banks; const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); return regionDisplay(region, isBanks, g_panel.focusedRegion); } // --- Drawing: a grid region --------------------------------------------------- // Draws one region's grid of thumbnails (or an empty-state line) clipped to its // viewport. `selectionOwner` is true when this region holds the live selection, so // its cells show selection/focus chrome; the other region draws plain. void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, const BankIndex* index, const std::string& emptyMsg, bool selectionOwner, const std::string& projectDir, Region reg) { const RECT grid = regionGridRect(region, isBanks); if (grid.bottom <= grid.top) return; if (!index || index->empty()) { kitText(bmp, toKitBox(grid), emptyMsg.c_str(), Font::Label, Role::TextDim, Align::Center); return; } // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense // BankIndex insertion order. Selection/focus are keyed by the occupied-ordinal (selection // space); a slot maps back to its ordinal via selectionForSlot. const RegionDisplay disp = regionDisplay(region, isBanks, reg); // FA3 gap-free: request one bin per drawn pixel column; drawWaveform's // peaks::columnMinMax exact partition makes every column gap-free — overbinning // produces byte-identical pixels at higher memory/CPU cost. computeThumbnail clamps // the request to the frame count. const int binWidth = kWaveformOversample * waveformColumnCount(KitBox{0, 0, kGrid.cellWidth, kGrid.cellHeight}); for (const SlotCellRect& r : disp.slotRects) { if (r.y >= grid.bottom) continue; // below the viewport: skip (no scroll) const CellRect rect{r.x, r.y, r.width, r.height}; const std::string id = disp.idAtSlot(r.slot); if (id.empty()) { // Interior gap slot: a subtle empty-slot treatment through the kit — a hairline // outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No // selection/focus/waveform, and not a hover or hit target (the grid never tracks // cell hover; a click on an empty slot clears selection like any grid miss). fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height}, Role::BgCell, InteractionState::Rest); LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(roleColor(Role::LineHairline)), 1.0f, 0); continue; } const Sample* s = index->query(id); if (!s) continue; // reconciled order should never name a stale id; defensive const int sel = disp.selectionForSlot(r.slot); const bool selected = selectionOwner && sel >= 0 && g_panel.selection.contains(sel); const bool focused = selectionOwner && sel >= 0 && g_panel.selection.focus == sel; const Envelope& env = thumbnailFor(*s, binWidth, projectDir); // Grid-cell hover is intentionally not tracked: the cell already carries selection + // focus chrome (the centerpiece's "bones"); a third transient hover state on every // cell would add repaint churn + visual noise. Hover lights the chrome/buttons/tabs. drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false, s); } } // L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but // ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag // source region). An accent/HOT outline (distinct from the accent/tertiary purple selection // border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline // so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op // for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0). void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) { if (!g_panel.dragging) return; if (g_panel.cardGesture != CardGesture::Reorder && g_panel.cardGesture != CardGesture::Replace) return; if (g_panel.dragSourceRegion != reg) return; // highlight only the source bank's grid if (g_panel.dragTargetSlot < 0) return; const RECT grid = regionGridRect(region, isBanks); const RegionDisplay disp = regionDisplay(region, isBanks, reg); // Use the drop rects (includes the trailing row past maxSlot) so a beyond-extent // target slot gets a visible highlight cue, not silence. const int gridW = grid.right - grid.left; const int maxSlot = disp.bank ? disp.bank->slots.maxSlot() : -1; std::vector dropRects = computeSlotRectsForDrop(maxSlot, gridW, kGrid); for (SlotCellRect& r : dropRects) { r.x += grid.left; r.y += grid.top; } for (const SlotCellRect& r : dropRects) { if (r.slot != g_panel.dragTargetSlot) continue; if (r.y >= grid.bottom) return; // below the viewport (no scroll) const LICE_pixel hot = toLice(roleColor(Role::AccentHot)); LICE_DrawRect(bmp, r.x, r.y, r.width, r.height, hot, 1.0f, 0); if (g_panel.cardGesture == CardGesture::Replace) LICE_DrawRect(bmp, r.x + 1, r.y + 1, r.width - 2, r.height - 2, hot, 1.0f, 0); return; } } // Draws a region header: title, the active-bank readout, and the full-height button. void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, const std::string& activeName, bool poolBtnIsPool) { const RECT hdr = regionHeaderRect(region); // Region header band (kit bg/panel — a raised region title bar). A hairline underline. fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top}, Role::BgPanel, InteractionState::Rest); LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); // Title, left (Font::Title — a region heading). The two regions are distinct KINDS of // container, so the title carries a CATEGORICAL accent (DS-2 revised: secondary/tertiary // mark kinds, never intensity) — Pool = secondary teal, Banks = tertiary purple. This is // a category mark, NOT the "what's live" signal (that stays the primary-lime "Active:" // readout beside it), keeping primary reserved for the live/active layer. RECT titleRc = hdr; titleRc.left += 8; titleRc.right = titleRc.left + 120; const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary; kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left); // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY // accent role in BOTH region headers so the active/capture-target bank is legible even when // it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2). const std::string readout = "Active: " + activeName; RECT actRc = hdr; actRc.left = titleRc.right + 6; actRc.right = createBtnRect(region).left - 6; if (actRc.right > actRc.left) kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left); // Full-height toggle button: an arrow glyph. In split it means "maximize this region"; // when this region is already full it means "restore the split". Kit drawButton + hover. const RECT btn = fullHtBtnRect(region); const bool thisFull = poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly) : (g_panel.fullHeight == BankPanelFullHeight::BanksOnly); const HoverKind hk = poolBtnIsPool ? HoverKind::FullHtPool : HoverKind::FullHtBanks; const InteractionState state = thisFull ? InteractionState::Active : hoverState(g_panel.hovered, hk, -1); const KitButtonBox box{KitBox{btn.left, btn.top, btn.right - btn.left, btn.bottom - btn.top}}; drawButton(bmp, box, thisFull ? "v" : "^", state, /*warn=*/false); } // Draws the named-banks tab strip: one tab per named bank (ordinal order), the SHOWN // tab highlighted, the ACTIVE bank's tab lit with the accent border, overflow // chevrons when present, plus the "+" create button in the header. During a drag, // the tab under the pointer gets the drop-target highlight. void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { const TabStripRect strip = banksTabStripRect(region); if (strip.height <= 0) return; // Tab strip band (kit bg/base — recessed relative to the region header above it). fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height}, Role::BgBase, InteractionState::Rest); const std::vector tabs = namedBanks(); const int n = static_cast(tabs.size()); if (n == 0) { kitText(bmp, KitBox{strip.x + 8, strip.y, strip.width - 8, strip.height}, "No named banks -- click + to create one.", Font::Label, Role::TextDim, Align::Left); return; } const TabStripLayout layout = computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); // Chevrons (drawn first so tabs sit above their inner edges). if (layout.overflow) { const KitBox lc{strip.x, strip.y, kTabSpec.chevronWidth, strip.height}; const KitBox rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y, kTabSpec.chevronWidth, strip.height}; fillSurface(bmp, lc, Role::BgCell, InteractionState::Rest); fillSurface(bmp, rc, Role::BgCell, InteractionState::Rest); kitText(bmp, lc, "<", Font::Label, Role::TextPrimary, Align::Center); kitText(bmp, rc, ">", Font::Label, Role::TextPrimary, Align::Center); } const std::string activeId = book() ? book()->activeBankId() : std::string(); const std::vector rects = computeTabRects(strip, n, kTabSpec, g_panel.tabScroll); for (const TabRect& tr : rects) { const Bank* bk = tabs[static_cast(tr.index)]; const bool shown = bk->id == g_panel.shownBankId; const bool active = bk->id == activeId; const bool dropHere = g_panel.dragging && g_panel.dropKind == DropKind::Tab && g_panel.dropBankId == bk->id; const bool hovered = g_panel.hovered.kind == HoverKind::Tab && g_panel.hovered.index == tr.index; // Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag // drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit); // else hover-or-rest bg/cell. const KitBox tb{tr.x, tr.y, tr.width, tr.height}; InteractionState state = InteractionState::Rest; if (active) state = InteractionState::Active; else if (dropHere) state = InteractionState::Dragging; else if (shown) state = InteractionState::Pressed; else if (hovered) state = InteractionState::Hover; fillSurface(bmp, tb, Role::BgCell, state); // The active bank's tab gets a bright accent border (unmistakable), distinct from the // shown tab's fill — active != shown, made visible (kit accent role). const KitColor border = active ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, toLice(border), 1.0f, 0); if (active) LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2, toLice(border), 1.0f, 0); // Label: bg/base on the accent-active fill for contrast, else text/primary. const Role trole = active ? Role::BgBase : Role::TextPrimary; kitText(bmp, KitBox{tr.x + 4, tr.y, tr.width - 8, tr.height}, bk->displayName.c_str(), Font::Label, trole, Align::Center); } } // The active bank's display name (for the readout). "Pool" when the pool is active. std::string activeBankName() { BankBook* b = book(); if (!b) return std::string(kPoolBankName); const Bank* bk = b->bank(b->activeBankId()); return bk ? bk->displayName : std::string(kPoolBankName); } // --- Full paint --------------------------------------------------------------- void paintPanel(HWND hwnd, HDC hdc) { RECT cr{}; GetClientRect(hwnd, &cr); const int w = cr.right - cr.left; const int h = cr.bottom - cr.top; if (w <= 0 || h <= 0) return; LICE_SysBitmap bmp(w, h); LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); const std::string projectDir = currentProjectDir(); const std::string activeName = activeBankName(); // Pool region (top). if (poolShown()) { const RECT region = poolRegionRect(w, h); drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true); drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool), "No samples in the pool yet. Capture one to see it here.", g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool); // Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid // outline signalling "drop here to move/copy into this bank"). Suppressed for a // same-bank reorder (that shows a per-SLOT highlight below, not the whole grid). if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion && (g_panel.cardGesture == CardGesture::Move || g_panel.cardGesture == CardGesture::Copy)) { const RECT grid = regionGridRect(region, false); LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, grid.right - grid.left - 2, grid.bottom - grid.top - 2, toLice(roleColor(Role::AccentHot)), 1.0f, 0); } // L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline // on the target slot's cell — distinct from the accent/tertiary purple selection // border, so it is never confusable with a selected card. drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool); } // Split divider. if (poolShown() && banksShown()) { const RECT body = splitBody(w, h); const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight, toLice(roleColor(Role::BgBase)), 1.0f, 0); } // Named-banks region (bottom). if (banksShown()) { const RECT region = banksRegionRect(w, h); drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false); // "+" create button (drawn as part of the banks header) — kit drawButton + hover. const RECT cbtn = createBtnRect(region); const InteractionState createState = hoverState(g_panel.hovered, HoverKind::CreateBank, -1); drawButton(&bmp, KitButtonBox{KitBox{cbtn.left, cbtn.top, cbtn.right - cbtn.left, cbtn.bottom - cbtn.top}}, "+", createState, /*warn=*/false); drawTabStrip(&bmp, region); drawRegionGrid(&bmp, region, /*isBanks=*/true, indexForRegion(Region::Banks), g_panel.shownBankId.empty() ? "Select or create a named bank." : "This bank is empty. Move samples here from the pool.", g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks); // Drop-target highlight for the banks region during a drag. BanksRegion fires // when the pointer is in the grid but not on a specific tab; Tab draws its own // highlight on the individual tab (drawTabStrip above handles that case). if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion && (g_panel.cardGesture == CardGesture::Move || g_panel.cardGesture == CardGesture::Copy)) { const RECT grid = regionGridRect(region, true); LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, grid.right - grid.left - 2, grid.bottom - grid.top - 2, toLice(roleColor(Role::AccentHot)), 1.0f, 0); } // L7 per-slot reorder/replace target highlight (source = banks region). drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks); } // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles // into the band MINUS the far-right More-button reserve; the More button is drawn over the // band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show // Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit // over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the // WHOLE top band first — otherwise the reserved right strip behind the More button is bare. fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest); drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton, /*topDivider=*/false); drawMoreButton(&bmp, w); drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton, /*topDivider=*/true); drawFooter(&bmp, w, h); // The custom hover-delay tooltip overlays everything (L5 refinement 2). drawTooltip(&bmp, w, h); BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } // --- Bank-change detection ---------------------------------------------------- // A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag // + per-sample id/path. Catches every mutation the panel must redraw for: capture, // project load, and B4's own create/rename/delete/move/activate. std::string bookFingerprint() { BankBook* b = book(); if (!b) return {}; std::string fp = std::to_string(b->size()); fp += '\x1e'; fp += b->activeBankId(); for (const Bank& bk : b->banks()) { fp += '\x1d'; fp += bk.id; fp += '\x1c'; fp += bk.displayName; for (const Sample& s : bk.index.all()) { fp += '\x1f'; fp += s.id; fp += '\x1f'; fp += s.relativePath; } } return fp; } // Reconciles shownBankId against the live named banks: keep it if it still names a // named bank; otherwise fall to the first named bank (or empty when none). Keeps the // banks region always showing a valid tab. Never touches the ACTIVE bank. void reconcileShownBank() { BankBook* b = book(); if (!b) { g_panel.shownBankId.clear(); return; } if (!g_panel.shownBankId.empty()) { const Bank* bk = b->bank(g_panel.shownBankId); if (bk && !bk->isPool()) return; // still valid } const std::vector named = namedBanks(); g_panel.shownBankId = named.empty() ? std::string() : named.front()->id; } bool refreshFingerprint() { if (!book()) return false; std::string fp = bookFingerprint(); if (fp == g_panel.bankFingerprint) return false; g_panel.bankFingerprint = std::move(fp); ++g_panel.generation; g_panel.cache.clear(); // The selection indexes into the OLD order; a change can invalidate those, so // clear it and stop any audition. if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { g_panel.selection = Selection{}; stopAudition(); } reconcileShownBank(); const BankIndex* idx = indexForRegion(g_panel.focusedRegion); g_panel.selItemCount = idx ? static_cast(idx->size()) : 0; return true; } // --- New-content detection (D2 Wave 2) ---------------------------------------- // // REAPER exposes no "item/track added" callback, so we diff live project state on the // existing timer. Each tick: enumerate every track GUID and every item GUID, diff // against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new // GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane // is exempt (design point #1) — its lane's durable name lacks the managed prefix. All // enumeration is READ-ONLY on the project; the only mutation is to the in-memory // membership index (persisted by persist on the next save, same as an action-driven tag). // True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified // in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so // bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace. constexpr int kFreeModeFixedLanes = 2; bool isFixedLaneTrack(MediaTrack* tr) { return static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; } // Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): // itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies. // Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set, // baseline input) and, for each item, records whether it sits on a manual lane so a // newly-detected item can be exempted from auto-tag without a second project walk. // `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a // newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand // guard) without a second project walk. // // Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack, // laneName) from lane_keys — the same predicate the apply path consults — so the exemption // rule is defined in exactly one place and is unit-tested there. void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, std::map& itemOnManualLane, std::map>& trackItemGuids) { const int trackCount = CountTracks(proj); for (int t = 0; t < trackCount; ++t) { MediaTrack* tr = GetTrack(proj, t); if (!tr) continue; std::string tg = guidString(tr); if (!tg.empty()) allGuids.insert(tg); // Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a // track-level attribute and is the same for every item on the track. const bool fixedLane = isFixedLaneTrack(tr); std::vector& itemsOnTrack = trackItemGuids[tg]; const int itemCount = CountTrackMediaItems(tr); for (int i = 0; i < itemCount; ++i) { MediaItem* it = GetTrackMediaItem(tr, i); if (!it) continue; std::string ig = itemGuid(it); if (ig.empty()) continue; allGuids.insert(ig); // Classify via the single shared predicate. For a fixed-lane track we read // the item's lane name; for a normal track we pass "" (isOnManualLane returns // false immediately for non-fixed-lane tracks regardless of name). const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; itemOnManualLane[ig] = isOnManualLane(fixedLane, ln); itemsOnTrack.push_back(ig); } } } // One detection tick: diff live GUIDs against the baseline and auto-tag the new ones // into the active mode. Runs every timer tick regardless of panel open/close (content // is created in the arrange). READ-ONLY on the project; mutates only the in-memory // membership index. // // INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a // background metadata update (like setting a label), not a destructive project edit. // persist.cpp writes it on the next project save alongside the bank and view state, the // same way an action-driven tag is persisted. Wrapping this in an Undo block would flood // the REAPER undo history with a new entry for every timer tick that sees new content. // Returns true iff this tick tagged at least one new GUID into a mode — the signal the // caller uses to decide whether to run the lane-minting pass (a track can only newly // become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint. bool detectNewContent() { if (!g_panel.session) return false; ReaProject* proj = EnumProjects(-1, nullptr, 0); // A project (re)load re-arms the first-poll guard so we never diff across two // projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded() // on the tick persist restores the project's membership + active mode, which sets // reloadPending. Draining it here re-baselines against the fully-loaded set (that // same tick's reapply-active-mode enumerated those tracks, so they are present), // and the observe() below returns nothing new — pre-existing untagged tracks stay // Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so // no separate first-tick handling is needed here. Using persist's GUID-primary load // signal (not a local pointer compare) is what fixes the reload-mis-tag: the two // identity checks can no longer diverge on a recycled ReaProject* address. if (g_panel.reloadPending) { g_panel.contentBaseline.reset(); g_panel.reloadPending = false; } std::set live; std::map itemOnManualLane; std::map> trackItemGuids; enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); const std::vector added = g_panel.contentBaseline.observe(live); if (added.empty()) return false; // first poll after open, or nothing new this tick ViewModeModel& model = g_panel.session->view(); // Which of `added` are items (the manual-lane map keys every item; track GUIDs never // appear there). Used below to exclude sibling new items from a track's PRE-EXISTING // mode set — a drop plus its own new siblings must not count each other as prior. const std::set newItemGuids = [&] { std::set s; for (const std::string& g : added) if (itemOnManualLane.count(g)) s.insert(g); return s; }(); // Item guid -> its track guid (reverse of trackItemGuids), so a new item's siblings // are found in one lookup. std::map trackOfItem; for (const auto& [trackGuid, items] : trackItemGuids) for (const std::string& ig : items) trackOfItem[ig] = trackGuid; // The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on // `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's // default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as // planLaneMinting ignores them when computing a track's own-item mode span, so the // adoption guard's view of the track matches the split decision's. Drives the adoption // / strand guard in autoTagNewContent. const auto preExistingTrackModes = [&](const std::string& trackGuid) -> std::set { std::set modes; auto it = trackItemGuids.find(trackGuid); if (it == trackItemGuids.end()) return modes; for (const std::string& sib : it->second) { if (newItemGuids.count(sib)) continue; // a sibling added THIS tick — not prior auto ml = itemOnManualLane.find(sib); if (ml != itemOnManualLane.end() && ml->second) continue; // manual lane — exempt const std::set m = model.membership().modesOf(sib); if (m.empty()) modes.insert(kArrangeModeId); // untagged ⇒ Arrange default else modes.insert(m.begin(), m.end()); } return modes; }; // Split the new GUIDs into tracks vs items so the pure decision can apply the // manual-lane exemption to items only. A GUID present in the item-lane map is an // item; otherwise it is a track (track GUIDs never appear in that map). std::vector newTracks; std::vector newItems; for (const std::string& g : added) { auto it = itemOnManualLane.find(g); if (it == itemOnManualLane.end()) { newTracks.push_back(g); // a track GUID } else { NewItem ni{g, it->second, {}}; auto tk = trackOfItem.find(g); if (tk != trackOfItem.end()) ni.trackModes = preExistingTrackModes(tk->second); newItems.push_back(std::move(ni)); // an item; carries exemption + track modes } } const std::vector tags = autoTagNewContent(newTracks, newItems, model.activeModeId()); for (const AutoTag& tag : tags) model.membership().tag(tag.guid, tag.modeId); return !tags.empty(); } // --- Audition preview --------------------------------------------------------- // // READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW // playback only. It NEVER inserts into the arrange, creates items/tracks, or // mutates the project or bank. PlayPreview streams a caller-owned PCM_source // through REAPER's preview bus and touches nothing in the project. // // FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by // signature/struct, not semantics — DAW-verify): // 1. REAPER's audio thread reads the preview_register_t by POINTER while the // preview is active (the struct's own comment mandates a cs/mutex we init), // so the register must outlive playback — we hold it in g_panel (static), // never on the stack. // 2. StopPreview is assumed to detach the source from the audio thread BEFORE it // returns, making it safe to PCM_Source_Destroy the source immediately after. // This is the conventional contract (SWS' preview helpers rely on it) but is // NOT documented in the header — flagged. If a rare race surfaced, the fix is // a StartPreviewFade + deferred free; not done now (YAGNI, no evidence). // 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not // set mono (&1024). volume 1.0, loop false, curpos 0. void initPreview() { if (g_panel.previewInited) return; #ifdef _WIN32 InitializeCriticalSection(&g_panel.preview.cs); #else pthread_mutex_init(&g_panel.preview.mutex, nullptr); #endif g_panel.previewInited = true; } void stopAudition() { if (g_panel.previewActive) { StopPreview(&g_panel.preview); g_panel.previewActive = false; } if (g_panel.previewSrc) { PCM_Source_Destroy(g_panel.previewSrc); g_panel.previewSrc = nullptr; } g_panel.preview.src = nullptr; } void deinitPreview() { if (!g_panel.previewInited) return; #ifdef _WIN32 DeleteCriticalSection(&g_panel.preview.cs); #else pthread_mutex_destroy(&g_panel.preview.mutex); #endif g_panel.previewInited = false; } // Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank. // L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw // BankIndex position. void startAudition(int idx) { stopAudition(); const BankIndex* index = indexForRegion(g_panel.focusedRegion); if (!index) return; const RegionDisplay disp = focusedDisplay(); if (idx < 0 || idx >= disp.occupiedCount()) return; const Sample* s = index->query(disp.orderedIds[static_cast(idx)]); if (!s) return; const std::string projectDir = currentProjectDir(); const std::string abs = resolveBankFile(projectDir, s->relativePath); if (abs.empty()) return; PCM_source* src = PCM_Source_CreateFromFile(abs.c_str()); if (!src) return; g_panel.preview.src = src; g_panel.preview.m_out_chan = 0; g_panel.preview.curpos = 0.0; g_panel.preview.loop = false; g_panel.preview.volume = 1.0; g_panel.preview.peakvol[0] = 0.0; g_panel.preview.peakvol[1] = 0.0; g_panel.preview.preview_track = nullptr; if (PlayPreview(&g_panel.preview) != 0) { g_panel.previewSrc = src; g_panel.previewActive = true; } else { PCM_Source_Destroy(src); g_panel.preview.src = nullptr; } } // --- Input helpers ------------------------------------------------------------ bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; } // Alt = replace modifier (L7) void invalidatePanel() { if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } // The item count the SELECTION reasons over — the focused region's occupied-cell count. // L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not // selectable). Occupied count == index size by construction: every index member maps to // exactly one occupied slot (gaps are empty slots, which the index never backs), so the // raw index size IS the dense selection-space extent. int focusedItemCount() { const BankIndex* idx = indexForRegion(g_panel.focusedRegion); return idx ? static_cast(idx->size()) : 0; } // Which region (if any) contains client point (x, y); returns false via `out` set to // Pool by default when the point is in neither region body. bool regionAt(int x, int y, Region& out) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; if (poolShown()) { const RECT r = poolRegionRect(w, h); if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { out = Region::Pool; return true; } } if (banksShown()) { const RECT r = banksRegionRect(w, h); if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { out = Region::Banks; return true; } } return false; } // --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- // // Each op mutates g_session.book() then persists via persistBankOp(). After a // STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankIndex& is invalid — we // resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an // unsaved project the empty-close discard in persistBankOp ensures no stale state // survives (matches the capture/B3 quiet-persist idiom). // REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). bool promptText(const char* title, const char* caption, const std::string& initial, std::string& out) { std::vector buf(512, '\0'); std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); const std::string captions = std::string(caption) + ",separator=\x1f"; if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), static_cast(buf.size()))) return false; std::string s(buf.data()); if (s.empty()) return false; out = std::move(s); return true; } // Mints a genuine REAPER GUID string as a stable bank id (same as B3 mintBankId). std::string mintBankId() { GUID g{}; genGuid(&g); char buf[64] = {0}; guidToString(&g, buf); return std::string(buf); } void doCreateBank() { if (!book()) return; std::string name; if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; const std::string id = mintBankId(); if (!book()->createBank(id, name)) { ShowMessageBox("A bank with that name already exists.", "ReaSampler: create bank", 0); return; } g_panel.shownBankId = id; // show the freshly-created bank g_panel.focusedRegion = Region::Banks; persistBankOp("ReaSampler: create bank"); invalidatePanel(); } void doRenameBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const std::string current = bk->displayName; // copy before any mutation std::string newName; if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; if (!book()->renameBank(bankId, newName)) { ShowMessageBox("Another bank already uses that name.", "ReaSampler: rename bank", 0); return; } persistBankOp("ReaSampler: rename bank"); invalidatePanel(); } // Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the // member count AND offers evacuate as the one-click alternative (Yes=delete anyway, // No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO. void doDeleteBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const std::size_t members = bk->index.size(); // read BEFORE any mutation const std::string name = bk->displayName; if (members > 0) { const std::string msg = "\"" + name + "\" holds " + std::to_string(members) + (members == 1 ? " sample" : " samples") + ".\n\nYes -- delete the bank AND drop its samples (files are kept on disk " "but no bank references them until prune).\nNo -- Evacuate them to the " "pool first, then delete the empty bank (keeps the samples).\nCancel -- " "do nothing."; // 3 == MB_YESNOCANCEL. 6=Yes, 7=No, 2=Cancel (SDK). const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 3); if (r == 2) return; // Cancel if (r == 7) { // No -> evacuate, then delete empty if (!book()->evacuate(bankId)) return; // book() may have reallocated; re-resolve nothing (we pass the id again). } // r == 6 (Yes) falls through to a plain delete (drops members). } if (!book()->deleteBank(bankId)) return; // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- // delete path moved/dropped members) — both change what a live instance could play. An // empty-bank delete is purely organizational, no bump. persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, // nudge focus to the pool so the selection has a valid home. if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; invalidatePanel(); } void doEvacuateBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; if (!book()->evacuate(bankId)) return; persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed invalidatePanel(); } void doActivateBank(const std::string& bankId) { if (!book()) return; if (!book()->setActiveBank(bankId)) return; // rejects an unknown id persistBankOp("ReaSampler: activate bank"); invalidatePanel(); } // Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass // ids straight to the model op (no BankIndex& cached across the loop's mutations). // // NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected): // * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally // before the dest add collapses on hash), so the index DID mutate — counts. // * COPY collapse: the source is left intact AND the dest already held the hash, // so NOTHING changed — a true index no-op. Must NOT open an undo point. // Hence: copy counts only real gains (Copied); move counts gains OR collapses. void transferSamples(const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, bool copy) { if (!book()) return; if (sampleIds.empty() || srcBankId == destBankId) return; if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return; int ok = 0, collapsed = 0; for (const std::string& sid : sampleIds) { const TransferResult r = copy ? book()->copySample(sid, srcBankId, destBankId) : book()->moveSample(sid, srcBankId, destBankId); switch (r) { case TransferResult::Moved: case TransferResult::Copied: ++ok; break; case TransferResult::Collapsed: ++collapsed; break; case TransferResult::RejectedUnknownBank: case TransferResult::RejectedSampleAbsent: case TransferResult::RejectedSameBank: break; } } const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); if (!mutated) return; // nothing changed — no persist, no undo point const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)"; persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed // The selection indexed into the source; after a move those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; invalidatePanel(); } // Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to // the file: a last-reference remove leaves the file on disk, orphaned until Phase R // prune — remove NEVER deletes bytes (the manifest is untouched). Removes are silent // (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) — // one Ctrl-Z restores the index entry. Ids passed by value — no BankIndex& cached // across the loop's mutations. void removeSamples(const std::vector& sampleIds, const std::string& srcBankId) { if (!book() || sampleIds.empty()) return; if (!book()->bank(srcBankId)) return; int removed = 0; for (const std::string& sid : sampleIds) if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) == RemoveResult::Removed) ++removed; if (removed == 0) return; // nothing changed — no persist, no undo point persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped // The selection indexed into the source; after a remove those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; invalidatePanel(); } // The selection's sample ids resolved against the FOCUSED region's bank (source of a // move/copy). Returns ids in bank order; empty when nothing selected. std::vector focusedSelectionIds() { // L7: selection ordinals index the DISPLAY (slot) order, not BankIndex insertion order. // orderedIds[i] is the id at selection ordinal i. std::vector ids; const RegionDisplay disp = focusedDisplay(); const int count = disp.occupiedCount(); for (int i : g_panel.selection.indices) if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast(i)]); return ids; } // Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to // the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4 // path machinery the panel uses for audition/insert (resolveBankFile over the current // project dir) — no temp copies; the drag points straight at the on-disk bank files. Each // id is looked up in its SOURCE bank's index (the payload's origin, not the focused region, // which can differ once the pointer roams), resolved, stat'd, then handed to the pure // drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no // mutation of sample / index / selection (invariant #2). std::vector resolveDragPathsForOs() { std::vector resolved; BankBook* b = book(); if (!b) return {}; const BankIndex* idx = b->index(g_panel.dragSourceBankId); if (!idx) return {}; const std::string projectDir = currentProjectDir(); resolved.reserve(g_panel.dragSampleIds.size()); for (const std::string& sid : g_panel.dragSampleIds) { const Sample* s = idx->query(sid); if (!s) continue; // stale id — the pure layer would skip it anyway; nothing to resolve ResolvedSample rs; rs.absolutePath = resolveBankFile(projectDir, s->relativePath); rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath)); resolved.push_back(std::move(rs)); } return assemblePathList(resolved).paths; } // --- Popup menus -------------------------------------------------------------- // // SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu // -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of // (label -> small int command), track it at screen coords, and switch on the return. // Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD // hands the chosen id straight back, so no hookcommand routing is involved. // Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both // accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative // position appending. Win32 and SWELL both treat pos < 0 as an append. void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) { UINT flags = MF_BYPOSITION | MF_STRING; if (grayed) flags |= MF_GRAYED; InsertMenu(menu, -1, flags, id, text); } void menuSeparator(HMENU menu) { InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr); } // Menu command ids (local to a popup). enum : unsigned int { kMenuNone = 0, kMenuActivate = 100, kMenuRename, kMenuDelete, kMenuEvacuate, kMenuCreate, kMenuRemove, // remove selected sample(s) from the source bank (B5) kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index }; // Shows the right-click context menu for a named-bank TAB: activate / rename / delete // / evacuate that bank, plus a create entry. Drives the id-keyed ops. void showTabMenu(int screenX, int screenY, const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const bool isActive = book()->activeBankId() == bankId; const bool nonEmpty = !bk->index.empty(); HMENU menu = CreatePopupMenu(); menuAppend(menu, kMenuActivate, isActive ? "Active (capture target)" : "Activate (make capture target)", /*grayed=*/isActive); menuSeparator(menu); menuAppend(menu, kMenuRename, "Rename..."); menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty); menuAppend(menu, kMenuDelete, "Delete..."); menuSeparator(menu); menuAppend(menu, kMenuCreate, "New bank..."); const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); switch (cmd) { case kMenuActivate: doActivateBank(bankId); break; case kMenuRename: doRenameBank(bankId); break; case kMenuEvacuate: doEvacuateBank(bankId); break; case kMenuDelete: doDeleteBank(bankId); break; case kMenuCreate: doCreateBank(); break; default: break; } } // Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the // chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup // (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we // resolve + fire the corresponding registered command id via the SAME contract the visible // buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above. void showMoreMenu() { if (!g_panel.hwnd) return; const std::vector rows = overflowMenuRows(); if (rows.empty()) return; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const MenuButtonRect mb = topMenuButtonRect(cr.right - cr.left); if (mb.empty()) return; HMENU menu = CreatePopupMenu(); for (std::size_t i = 0; i < rows.size(); ++i) { const int cmd = resolveBarCommandId(rows[i]); // Grey a variant not registered on this channel (defensive — all three are registered). menuAppend(menu, static_cast(i + 1), rows[i].fullName.c_str(), /*grayed=*/cmd == 0); } // Anchor the popup at the button's bottom-left, in screen coords. POINT pt{mb.x, mb.y + mb.height}; ClientToScreen(g_panel.hwnd, &pt); const int chosen = TrackPopupMenu(menu, TPM_RETURNCMD, pt.x, pt.y, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); if (chosen >= 1 && chosen <= static_cast(rows.size())) { const int cmd = resolveBarCommandId(rows[static_cast(chosen - 1)]); if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); } } // Shows the move/copy menu for the current selection (the SOURCE is the focused // region's bank). Lists every OTHER bank (pool + named) as a move destination, then a // copy submenu-free flat list (copy entries follow the move block). Move is the // default (listed first); copy is the deliberate secondary act. void showSelectionMenu(int screenX, int screenY) { const std::vector sel = focusedSelectionIds(); if (sel.empty()) return; const std::string srcId = bankIdForRegion(g_panel.focusedRegion); // Destinations: pool + named banks, excluding the source. Ordinal order. struct Dest { std::string id; std::string name; }; std::vector dests; if (srcId != std::string(kPoolBankId)) dests.push_back({std::string(kPoolBankId), std::string(kPoolBankName)}); for (const Bank* bk : namedBanks()) if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); const std::string label = std::to_string(sel.size()) + (sel.size() == 1 ? " sample" : " samples"); HMENU menu = CreatePopupMenu(); // Move/copy blocks appear only when there is another bank to transfer to; Remove is // always offered (it needs no destination — it drops the entry from the source). if (!dests.empty()) { menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); for (std::size_t i = 0; i < dests.size(); ++i) menuAppend(menu, kMenuMoveBase + static_cast(i), (" " + dests[i].name).c_str()); menuSeparator(menu); menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); for (std::size_t i = 0; i < dests.size(); ++i) menuAppend(menu, kMenuCopyBase + static_cast(i), (" " + dests[i].name).c_str()); menuSeparator(menu); } menuAppend(menu, kMenuRemove, ("Remove " + label + "...").c_str()); const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); if (cmd == static_cast(kMenuRemove)) { removeSamples(sel, srcId); } else if (cmd >= static_cast(kMenuMoveBase) && cmd < static_cast(kMenuMoveBase + dests.size())) { transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); } else if (cmd >= static_cast(kMenuCopyBase) && cmd < static_cast(kMenuCopyBase + dests.size())) { transferSamples(sel, srcId, dests[cmd - kMenuCopyBase].id, /*copy=*/true); } } // --- Click routing ------------------------------------------------------------ // Handles a header/tab-strip/button click for the banks region. Returns true if the // click was consumed (a region-chrome hit), false to fall through to grid selection. bool handleBanksChromeClick(int x, int y, const RECT& region) { // Full-height toggle button. const RECT ftb = fullHtBtnRect(region); if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { bankPanelToggledBanksFullHeight(); return true; } // "+" create button. const RECT cb = createBtnRect(region); if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) { doCreateBank(); return true; } // Tab strip: chevrons scroll, a tab click SHOWS that bank (browse — NOT activate). const TabStripRect strip = banksTabStripRect(region); const std::vector tabs = namedBanks(); const int n = static_cast(tabs.size()); const TabHit hit = hitTestTabStrip(x, y, strip, n, kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::ScrollLeft || hit.kind == TabHitKind::ScrollRight) { const TabStripLayout layout = computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); const int step = kTabSpec.tabWidth; const int desired = g_panel.tabScroll + (hit.kind == TabHitKind::ScrollLeft ? -step : step); g_panel.tabScroll = clampTabScroll(desired, layout); invalidatePanel(); return true; } if (hit.kind == TabHitKind::Tab) { const Bank* bk = tabs[static_cast(hit.index)]; if (bk->id != g_panel.shownBankId) { g_panel.shownBankId = bk->id; // browse: show this bank's grid g_panel.selection = Selection{}; // grid changed — reset selection stopAudition(); } g_panel.focusedRegion = Region::Banks; invalidatePanel(); return true; } return false; } // Handles the pool region's full-height toggle. Returns true if consumed. bool handlePoolChromeClick(int x, int y, const RECT& region) { const RECT ftb = fullHtBtnRect(region); if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { bankPanelToggledPoolFullHeight(); return true; } return false; } // The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by // mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box. int footerToggleSegmentHit(int x, int y, int w, int h) { if (!g_panel.session) return -1; const FooterBarLayout fb = footerBarLayoutFor(w, h); if (fb.toggle.empty()) return -1; const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; return hitTestSegment(x, y, th, modeCount()); } // Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) / // bottom toolbar / region chrome / grid selection, and arm a potential drag when the click // lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom. void handleClick(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; // TOP toolbar: the far-right More button first (its rect sits in the band's reserved right // strip, outside the action rect), then the frequent capture/placement buttons. A button // fires its registered action via the command-id contract; the band is claimed whole (a // gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts. { const MenuButtonRect mb = topMenuButtonRect(w); if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; } } if (handleToolbarClick(x, y, topToolbarActionRect(w), topBarRows())) return; // Claim the WHOLE top band (including the reserved right strip between the last button and // the More button) so a click there is inert chrome, never a fall-through to the grid. if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return; // Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design] // toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a // click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar / // grid so a footer click never selects a cell. { const int seg = footerToggleSegmentHit(x, y, w, h); if (seg >= 0) { const std::vector& modes = g_panel.session->view().modes().all(); if (seg < static_cast(modes.size())) { applyMode(g_panel.session->view(), modes[static_cast(seg)].id, nullptr); invalidatePanel(); } return; } const FooterBarLayout fb = footerBarLayoutFor(w, h); if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) { // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates // the SESSION's tail setting (capture reads it; persist saves it with the project) // and marks the project dirty — touches NOTHING in the bank/arrange. TailSetting& tail = g_panel.session->tail(); tail.mode = cycleTailMode(tail.mode); markTailDirty(); invalidatePanel(); return; } // Prune button (R3): fires the "Prune bank folder" action THROUGH its registered // command id (fork R-E: dispatch the command, not the session directly) so the panel // affordance and the bindable action share the one guarded dry-run/confirm/delete path // in doBankPruneFolder. A 0 id (pre-registration) no-ops. const ButtonRect pb = pruneButtonRectFor(w, h); if (hitTestPruneButton(x, y, pb)) { const int cmd = bankPruneCommandId(); if (cmd != 0) Main_OnCommand(cmd, 0); return; } } // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the // command-id contract. Claimed whole like the top toolbar. if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return; // Region chrome (headers, tab strip, buttons). if (poolShown()) { const RECT pr = poolRegionRect(w, h); if (y >= pr.top && y < regionGridRect(pr, false).top) { if (handlePoolChromeClick(x, y, pr)) return; } } if (banksShown()) { const RECT br = banksRegionRect(w, h); if (y >= br.top && y < regionGridRect(br, true).top) { if (handleBanksChromeClick(x, y, br)) return; } } // Grid selection. Resolve which region's grid the point is in. Region reg = Region::Pool; if (!regionAt(x, y, reg)) return; const bool isBanks = reg == Region::Banks; const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); // L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An // empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a // click on a gap clears selection, exactly like a click in the margin) — empty slots // are decorative, not selectable. const RegionDisplay disp = regionDisplay(region, isBanks, reg); const int hitSlot = hitTestSlot(x, y, disp.slotRects); const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot); const int count = disp.occupiedCount(); // Switching focus region reseeds the selection there. if (g_panel.focusedRegion != reg) { g_panel.focusedRegion = reg; g_panel.selection = Selection{}; stopAudition(); } if (hit < 0) { if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { g_panel.selection = Selection{}; stopAudition(); } invalidatePanel(); return; } // Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell: // // • Already-selected cell: defer the selection change to LBUTTONUP so a plain // press on a multi-selection doesn't collapse it before we know whether a drag // will happen. Arm the drag with the current (multi-)selection as the payload // candidate; only the caret moves immediately. // // • Unselected cell: apply the plain-click selection immediately (collapses to // the single pressed cell) THEN arm a drag from it — so the user can press-and- // drag in one gesture without a prior selecting click. The selection is set // before arming so that focusedSelectionIds() resolves the right payload when // the threshold is crossed in onMouseMove. // // ctrl / shift presses are selection-only gestures — no drag arm in either case. const bool onSelected = g_panel.selection.contains(hit); if (!ctrlDown() && !shiftDown()) { if (!onSelected) { // Commit the single-cell selection now so the drag payload is correct. g_panel.selection = applyClick(g_panel.selection, hit, false, false, count); g_panel.selItemCount = count; } else { // Move the caret to the pressed cell; defer collapsing multi-selection. g_panel.selection.focus = hit; } g_panel.dragArmed = true; g_panel.dragStartX = x; g_panel.dragStartY = y; g_panel.dragSourceRegion = reg; // Capture the mouse NOW so WM_MOUSEMOVE is delivered even when the pointer leaves the // panel client rect before the drag threshold is crossed. Without capture, outside moves // are not delivered, so a fast straight-out drag never transitions dragArmed → dragging // and the OS drag-out never fires on the first pass. The capture is released on button-up // (no drag: onLBtnUp dragArmed branch; drag: OsDrag path or onLBtnUp dragging branch) // and on WM_CAPTURECHANGED (stolen or external release — already calls resetDragState). SetCapture(g_panel.hwnd); invalidatePanel(); return; } g_panel.selection = applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count); g_panel.selItemCount = count; invalidatePanel(); } // Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`. // Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is // over the footer strip AND the mode is Manual — wheel up lengthens, down shortens, // clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns // false so the caller can let REAPER/the docker handle the wheel normally). On a real // change it mutates the SESSION's tail setting, marks the project dirty (so it saves), // and repaints the live length. Returns true iff the wheel was consumed. bool handleWheel(int x, int y, int delta) { if (!g_panel.session) return false; if (!pointInFooter(x, y)) return false; TailSetting& tail = g_panel.session->tail(); if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only // One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad // that sends fractional deltas still steps predictably. Sign carries direction. const int notches = delta / 120; if (notches == 0) return false; // sub-notch movement — nothing to apply yet const double before = tail.manualMs; tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs); if (tail.manualMs == before) return true; // already at a bound — consumed, no change markTailDirty(); invalidatePanel(); // label shows the new length live return true; } // The column count for a region's current grid width (nav needs the layout's wrap). int columnsForRegion(Region reg) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; const RECT region = reg == Region::Banks ? banksRegionRect(w, h) : poolRegionRect(w, h); const RECT grid = regionGridRect(region, reg == Region::Banks); return columnsForWidth(grid.right - grid.left, kGrid); } bool isOurWindow(HWND hwnd) { for (HWND w = hwnd; w; w = GetParent(w)) if (w == g_panel.hwnd) return true; return false; } bool handleKey(int vk) { const int count = focusedItemCount(); if (count <= 0) return false; switch (vk) { case VK_LEFT: case VK_RIGHT: case VK_UP: case VK_DOWN: { const NavKey nk = vk == VK_LEFT ? NavKey::Left : vk == VK_RIGHT ? NavKey::Right : vk == VK_UP ? NavKey::Up : NavKey::Down; g_panel.selection = navigate(g_panel.selection, nk, columnsForRegion(g_panel.focusedRegion), count, shiftDown()); g_panel.selItemCount = count; invalidatePanel(); return true; } case VK_RETURN: case VK_SPACE: if (g_panel.selection.focus >= 0) startAudition(g_panel.selection.focus); return true; case VK_ESCAPE: stopAudition(); return true; case VK_DELETE: { // Remove the focused-region selection (B5). Silent; a no-op when nothing // is selected. const std::vector sel = focusedSelectionIds(); if (sel.empty()) return false; // nothing selected — let the key fall through removeSamples(sel, bankIdForRegion(g_panel.focusedRegion)); return true; } default: return false; } } int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) { if (!msg || msg->message != WM_KEYDOWN) return 0; if (!g_panel.open || !g_panel.hwnd) return 0; if (!isOurWindow(GetFocus())) return 0; return handleKey(static_cast(msg->wParam)) ? 1 : 0; } accelerator_register_t g_accel{translateAccel, true, nullptr}; bool g_accelRegistered = false; void registerAccel() { if (g_accelRegistered || !g_rec) return; g_rec->Register("accelerator", &g_accel); g_accelRegistered = true; } void unregisterAccel() { if (!g_accelRegistered || !g_rec) return; g_rec->Register("-accelerator", &g_accel); g_accelRegistered = false; } // --- Drag (move between regions/onto a tab) ----------------------------------- constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag // Resolves the drop target under client (x, y) during a drag, updating dropKind / // dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank; // anywhere else -> none. void updateDropTarget(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); if (banksShown()) { const RECT br = banksRegionRect(w, h); const TabStripRect strip = banksTabStripRect(br); const std::vector tabs = namedBanks(); const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::Tab) { g_panel.dropKind = DropKind::Tab; g_panel.dropBankId = tabs[static_cast(hit.index)]->id; return; } // Tab takes precedence over the region; if the point is in the banks region but // not on a specific tab, treat the whole grid as a drop zone for the shown bank. // No valid target when there are no named banks or no shown bank. if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) { if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) { g_panel.dropKind = DropKind::BanksRegion; g_panel.dropBankId = g_panel.shownBankId; return; } } } if (poolShown()) { const RECT pr = poolRegionRect(w, h); const RECT grid = regionGridRect(pr, false); if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) { g_panel.dropKind = DropKind::PoolRegion; return; } } } // The destination bank id under the current drop target (pool id for PoolRegion; the tab/ // shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's // dropKind/dropBankId — the single source of "what bank is under the pointer". std::string dropTargetBankId() { switch (g_panel.dropKind) { case DropKind::PoolRegion: return std::string(kPoolBankId); case DropKind::Tab: case DropKind::BanksRegion: return g_panel.dropBankId; case DropKind::None: return {}; } return {}; } // L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) // the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget // so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the // precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace); // the shell only supplies the region verdict, the same-bank target slot + occupancy, and the // live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path // in onMouseMove BEFORE this runs, so here the pointer is always inside the client. void classifyCardDrag(int x, int y) { g_panel.cardGesture = CardGesture::None; g_panel.dragTargetSlot = -1; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; const PanelClientRect client{cr.left, cr.top, w, h}; const std::string destBank = dropTargetBankId(); DragModifiers mods; mods.ctrl = ctrlDown(); mods.alt = altDown(); if (!destBank.empty() && destBank == g_panel.dragSourceBankId) { // Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over // in the SOURCE bank's own region display + whether it is occupied. // Uses computeSlotRectsForDrop (one trailing row past maxSlot) so a drop beyond // the last occupied card resolves to a valid trailing slot, not a -1 miss. mods.region = DropRegion::SameBankGrid; const bool isBanks = g_panel.dragSourceRegion == Region::Banks; const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); const RECT grid = regionGridRect(region, isBanks); const int gridW = grid.right - grid.left; const std::vector dropRects = computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1, gridW, kGrid); // Translate the drop rects to client space (matching regionDisplay's translation). std::vector dropRectsClient = dropRects; for (SlotCellRect& r : dropRectsClient) { r.x += grid.left; r.y += grid.top; } const int slot = hitTestSlot(x, y, dropRectsClient); mods.targetSlot = slot; mods.slotOccupied = slot >= 0 && !disp.idAtSlot(slot).empty(); g_panel.dragTargetSlot = slot; } else if (!destBank.empty()) { mods.region = DropRegion::OtherBankOrTab; // move/copy to a different bank/tab } else { mods.region = DropRegion::DeadSpace; // header/footer/gap — a no-op drop } const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; g_panel.cardGesture = decideCardGesture(x, y, client, st, mods); } // Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure // (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice. // Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_* // set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move -> // IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor // exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would // need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap // occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot); // OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is // never seen — the handoff happens before this runs); Default/None -> IDC_ARROW. void applyDragCursor(CardGesture g) { const char* idc = IDC_ARROW; switch (cursorForGesture(g)) { case CursorCue::Reorder: idc = IDC_SIZEALL; break; case CursorCue::Move: idc = IDC_HAND; break; case CursorCue::Copy: idc = IDC_UPARROW; break; case CursorCue::Replace: idc = IDC_SIZEWE; break; case CursorCue::OsDragOut: return; // OS drag owns the cursor; do not fight it case CursorCue::Default: idc = IDC_ARROW; break; } SetCursor(LoadCursor(nullptr, idc)); } // Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring // handleClick's precedence exactly (so the element that lights on hover is the one a click // would hit). Returns HoverKind::None for the grid / dead space / a point outside the client // (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure // resolution over the same pure geometry the click path uses. Hover resolveHover(int x, int y) { if (!g_panel.hwnd) return Hover{}; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; // TOP toolbar: the far-right More button, then the frequent buttons (matching the click // order — first zone top-to-bottom). { const MenuButtonRect mb = topMenuButtonRect(w); if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1}; const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows()); if (hit >= 0) return Hover{HoverKind::TopBarButton, hit}; } // Footer: mode-toggle segments, Tail button, then Prune (matching the click order). { const int seg = footerToggleSegmentHit(x, y, w, h); if (seg >= 0) return Hover{HoverKind::ModeSegment, seg}; const FooterBarLayout fb = footerBarLayoutFor(w, h); if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1}; const ButtonRect pb = pruneButtonRectFor(w, h); if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; } // BOTTOM toolbar buttons. { const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows()); if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit}; } // Region chrome: full-height toggles, create button, tabs. if (poolShown()) { const RECT pr = poolRegionRect(w, h); const RECT ftb = fullHtBtnRect(pr); if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) return Hover{HoverKind::FullHtPool, -1}; } if (banksShown()) { const RECT br = banksRegionRect(w, h); const RECT ftb = fullHtBtnRect(br); if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) return Hover{HoverKind::FullHtBanks, -1}; const RECT cb = createBtnRect(br); if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) return Hover{HoverKind::CreateBank, -1}; const TabStripRect strip = banksTabStripRect(br); const std::vector tabs = namedBanks(); const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index}; } return Hover{}; } // Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no // per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also // resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only // appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the // poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running. void updateHover(int x, int y) { const Hover next = resolveHover(x, y); if (next != g_panel.hovered) { g_panel.hovered = next; g_panel.hoverSinceTick = GetTickCount(); if (g_panel.tooltipShown) { g_panel.tooltipShown = false; } invalidatePanel(); } } // Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past // kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the // OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer; // WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the // current hover has no tooltip (grid / chrome / the More button). void maybeShowTooltip() { if (g_panel.tooltipShown) return; const HoverKind k = g_panel.hovered.kind; if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return; const unsigned int now = GetTickCount(); if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) { g_panel.tooltipShown = true; invalidatePanel(); } } void onMouseMove(int x, int y) { // Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns // the visual feedback then — a drop-target highlight, not a hover). Cleared to None when // the pointer is over the grid / dead space. if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); if (g_panel.dragArmed && !g_panel.dragging) { if (std::abs(x - g_panel.dragStartX) > kDragThreshold || std::abs(y - g_panel.dragStartY) > kDragThreshold) { // Threshold crossed — begin the drag. Snapshot the payload NOW. g_panel.dragging = true; g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); g_panel.dragSampleIds = focusedSelectionIds(); // The single card actually grabbed = the focus ordinal's id. This is the L7 // in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card // gesture, distinct from the multi-select move/copy payload in dragSampleIds. { const RegionDisplay disp = focusedDisplay(); const int f = g_panel.selection.focus; g_panel.dragPrimaryId = (f >= 0 && f < disp.occupiedCount()) ? disp.orderedIds[static_cast(f)] : std::string{}; } g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now g_panel.tooltipShown = false; // a drag never shows a tooltip // SetCapture was already called at drag-arm time (handleClick); no re-capture needed. } } if (g_panel.dragging) { // M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the // pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant // #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture // splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an // InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a // pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays // Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint. RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top}; const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom); DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; st.singleCapture = (g_panel.dragSampleIds.size() == 1); // Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can // only arise there) and only for a single-capture payload — the SDK hit-test is skipped // on the common internal-drag path so it costs nothing there. The screen conversion is // Windows-only (D5); resolveFxDropTarget owns the REAPER hit query. FxDropTarget fx; if (!inside && st.singleCapture) { POINT sp{x, y}; ClientToScreen(g_panel.hwnd, &sp); fx = resolveFxDropTarget(sp.x, sp.y); st.overReaperUi = fx.overReaperUi; } const DragGesture gesture = decideGesture(x, y, client, st); if (gesture == DragGesture::InstrumentDrop) { // Track the FX hotspot for the release; the highlight is REAPER's own FX-button // hover feedback under the pointer (the drop is driven on button-up). We keep the // internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does // NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the // panel does not also paint a bank-drop cue while the drag is out over a track. g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); invalidatePanel(); return; } // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target. g_panel.instrumentDropTrack = nullptr; if (gesture == DragGesture::OsDrag) { // Resolve the payload to existing on-disk paths BEFORE tearing down internal // drag state (the resolver reads dragSourceBankId / dragSampleIds). const std::vector paths = resolveDragPathsForOs(); // Reset internal drag state and release capture NOW: DoDragDrop runs its own // modal loop and takes over mouse capture, so the internal drag must be fully // wound down first (no stale dragging/dropKind, no lingering SetCapture). A // cancelled/empty OS drag therefore leaves the panel in a clean, no-op state // (invariant #2 — nothing mutated). if (GetCapture() == g_panel.hwnd) ReleaseCapture(); g_panel.dragArmed = false; g_panel.dragging = false; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); g_panel.cardGesture = CardGesture::None; g_panel.dragTargetSlot = -1; g_panel.dragPrimaryId.clear(); invalidatePanel(); // Empty path list -> nothing draggable (all stale/missing); do not start a drag. if (!paths.empty()) initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows return; } // Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing // move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/ // dropBankId are current for classifyCardDrag's same-vs-other-bank decision. updateDropTarget(x, y); classifyCardDrag(x, y); applyDragCursor(g_panel.cardGesture); invalidatePanel(); } } // L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap- // preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure // BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the // batched undo point + saves). A no-op reorder (already at the target, model returns false) // opens no undo point. Selection reasons over slot order, so it is cleared after — the // fingerprint pass rebuilds it against the new order. void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return; if (!book()->reorderSample(id, bankId, targetSlot)) return; // rejected/no-op: no undo point persistBankOp("ReaSampler: reorder sample"); g_panel.selection = Selection{}; invalidatePanel(); } // L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is // removed from the bank's index (index-only, file untouched — pool guard enforced in the pure // BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert, // no undo point (per spec). One drop = one Ctrl-Z on success. void doReplaceDrop(const std::string& newId, const std::string& oldId, const std::string& bankId) { if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return; if (!book()->replaceSample(newId, oldId, bankId)) return; // pool-guard reject: NO-OP persistBankOp("ReaSampler: replace sample"); g_panel.selection = Selection{}; invalidatePanel(); } // Clears all drag-state fields to their resting values. Called from every exit path // (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields // stays consistent across all four sites. void resetDragState() { g_panel.dragArmed = false; g_panel.dragging = false; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); g_panel.cardGesture = CardGesture::None; g_panel.dragTargetSlot = -1; g_panel.dragPrimaryId.clear(); g_panel.instrumentDropTrack = nullptr; } // Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: // * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each. // * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy). // * None -> a drop over dead space / the source-bank gap = no-op. // OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove. void onLBtnUp(int x, int y) { if (g_panel.dragging) { // S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a // ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move, // NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank // drop (the pointer is out over a track, not over a bank region). Single-capture only (the // gesture never armed for a multi payload), so dragSampleIds.front() is the capture. if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) { const std::string sampleId = g_panel.dragSampleIds.front(); performInstrumentDrop(g_panel.instrumentDropTrack, buildInstrumentDropPreset(sampleId)); // Read-only over the bank + arrange: the ONLY mutations are the new FX instance + // its state (both undoable in performInstrumentDrop). No book change, no ext-state, // no dirty-mark here. } else { updateDropTarget(x, y); classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) const CardGesture g = g_panel.cardGesture; if (g == CardGesture::Reorder) { doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, g_panel.dragTargetSlot); } else if (g == CardGesture::Replace) { // Replace targets the OCCUPANT of the target slot with the single grabbed card. const bool isBanks = g_panel.dragSourceRegion == Region::Banks; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot); // Replace only makes sense for a single grabbed card over a DIFFERENT occupant. if (!occupant.empty() && occupant != g_panel.dragPrimaryId) doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId); } else if (g == CardGesture::Move || g == CardGesture::Copy) { const std::string destId = dropTargetBankId(); if (!destId.empty() && destId != g_panel.dragSourceBankId && !g_panel.dragSampleIds.empty()) { transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, /*copy=*/g == CardGesture::Copy); } } } // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None). SetCursor(LoadCursor(nullptr, IDC_ARROW)); // restore the arrow on drop if (GetCapture() == g_panel.hwnd) ReleaseCapture(); } else if (g_panel.dragArmed) { // Press-release on a selected cell with no drag: treat as a plain click that // collapses the multi-selection to the pressed cell (standard behavior). // Release capture acquired at arm time (handleClick) — drag never started. if (GetCapture() == g_panel.hwnd) ReleaseCapture(); const BankIndex* idx = indexForRegion(g_panel.focusedRegion); const int count = idx ? static_cast(idx->size()) : 0; const int focus = g_panel.selection.focus; if (focus >= 0) g_panel.selection = applyClick(g_panel.selection, focus, false, false, count); } resetDragState(); invalidatePanel(); } // A right-click: on a named tab -> the tab management menu; on a grid cell of the // focused region with a selection -> the move/copy menu. void handleRightClick(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; // Tab management menu. if (banksShown()) { const RECT br = banksRegionRect(w, h); const TabStripRect strip = banksTabStripRect(br); const std::vector tabs = namedBanks(); const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::Tab) { POINT pt{x, y}; ClientToScreen(g_panel.hwnd, &pt); showTabMenu(pt.x, pt.y, tabs[static_cast(hit.index)]->id); return; } } // Grid selection menu (move/copy). Only when the right-click lands in the focused // region's grid and there is a selection. Region reg = Region::Pool; if (regionAt(x, y, reg) && reg == g_panel.focusedRegion && !g_panel.selection.empty()) { POINT pt{x, y}; ClientToScreen(g_panel.hwnd, &pt); showSelectionMenu(pt.x, pt.y); } } // --- Dialog proc + docking ---------------------------------------------------- // Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands // them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all into the active // bank (bank-fill only — no assignment to any live instance). Always DragFinish's the HDROP // (frees the shell-allocated drop buffer) on every path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) // returns the file count; then each path is queried by index. Both Win32 and SWELL expose // DragQueryFile/DragFinish with this contract. void handleDropFiles(HDROP hDrop) { std::vector paths; const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); paths.reserve(count); for (UINT i = 0; i < count; ++i) { // Query the required length first (excludes the NUL), then read into a sized buffer. const UINT len = DragQueryFile(hDrop, i, nullptr, 0); if (len == 0) continue; std::vector buf(static_cast(len) + 1, '\0'); DragQueryFile(hDrop, i, buf.data(), static_cast(buf.size())); std::string p(buf.data()); if (!p.empty()) paths.push_back(std::move(p)); } DragFinish(hDrop); if (!paths.empty()) ingestDroppedFiles(paths); } WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DROPFILES: // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import // into the active bank (bank-fill only). wParam is the HDROP. handleDropFiles(reinterpret_cast(wParam)); return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); paintPanel(hwnd, hdc); EndPaint(hwnd, &ps); return 0; } case WM_LBUTTONDOWN: { SetFocus(hwnd); handleClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; } case WM_MOUSEMOVE: onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_LBUTTONUP: onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_RBUTTONDOWN: SetFocus(hwnd); handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_CAPTURECHANGED: // Capture lost (pointer left window pre-threshold and released outside, or another // window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is // mutated on a cancel; the cursor is restored to the arrow. if (g_panel.dragArmed || g_panel.dragging) { resetDragState(); SetCursor(LoadCursor(nullptr, IDC_ARROW)); invalidatePanel(); } return 0; case WM_MOUSEWHEEL: { // Fine-adjust the Manual tail length when the wheel is over the footer. // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so // convert to client space before hit-testing the footer. The signed wheel // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120, // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer // handler acts, so scrolling elsewhere in the dock still behaves normally. POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; ScreenToClient(hwnd, &pt); const int delta = static_cast(HIWORD(wParam)); return handleWheel(pt.x, pt.y, delta) ? 1 : 0; } case WM_DESTROY: if (GetCapture() == hwnd) ReleaseCapture(); stopAudition(); g_panel.selection = Selection{}; resetDragState(); g_panel.hovered = Hover{}; g_panel.tooltipShown = false; g_panel.hwnd = nullptr; g_panel.open = false; return 0; default: break; } return 0; } void openPanel() { if (g_panel.open && g_panel.hwnd) { DockWindowActivate(g_panel.hwnd); return; } initPreview(); // Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so // a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts // are torn down once at bankPanelShutdown. All panel text draws through these. kitFontsInit(); g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), GetMainHwnd(), dlgProc, 0); if (!g_panel.hwnd) return; // Channel-qualified dock identity (Phase V, V4). The title and the persisted-position // identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler // Bank beta") and does not fight over stable's saved dock slot (the identstr is a // REAPER-global collision surface — it keys the persisted dock position). DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true); DockWindowActivate(g_panel.hwnd); g_panel.open = true; // S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles). // DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it, // so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3 // instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration // surface is out of scope for this dispatch. WM_DROPFILES handling itself uses // DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means // would still ingest — only the accept opt-in is gated. #ifdef _WIN32 DragAcceptFiles(g_panel.hwnd, TRUE); #endif registerAccel(); reconcileShownBank(); refreshFingerprint(); } void closePanel() { if (GetCapture() == g_panel.hwnd) ReleaseCapture(); stopAudition(); g_panel.selection = Selection{}; resetDragState(); unregisterAccel(); if (g_panel.hwnd) { DockWindowRemove(g_panel.hwnd); DestroyWindow(g_panel.hwnd); g_panel.hwnd = nullptr; } g_panel.open = false; } } // namespace // --- Public API --------------------------------------------------------------- void bankPanelInit(ReaSamplerSession* session) { g_panel.session = session; } // Returns true only when the panel window is actually visible to the user right now. // IsWindowVisible() returns false when the docker is hidden via Alt+D even though the // HWND and g_panel.open are still live — the live query is the source of truth for // toggle decisions and the Actions-list checkmark (OnToggleAction in main.cpp). static bool panelEffectivelyVisible() { return g_panel.hwnd && IsWindowVisible(g_panel.hwnd); } void bankPanelToggle() { // Decide from live visibility, not the cached g_panel.open flag. // Alt+D hides the docker without destroying the window, leaving g_panel.open // stale (true) while the panel is gone. Using IsWindowVisible avoids the // double-fire needed to re-show the panel after a docker hide. if (panelEffectivelyVisible()) closePanel(); else openPanel(); } bool bankPanelIsOpen() { // Derive from live window state so the Actions-list checkmark stays honest // even after Alt+D hides the docker without notifying the extension. return panelEffectivelyVisible(); } std::vector bankPanelSelectedSampleIds() { return focusedSelectionIds(); } std::string bankPanelSelectedSourceBankId() { // The focused region's displayed bank is the move/copy source. Default to the // pool (a safe source) when nothing is selected / the panel never opened. if (g_panel.selection.empty()) return std::string(kPoolBankId); const std::string id = bankIdForRegion(g_panel.focusedRegion); return id.empty() ? std::string(kPoolBankId) : id; } void bankPanelNotifyProjectLoaded() { // Persist restored a project's membership + active mode this tick (main.cpp calls // this from the same consumeLoadSignal() branch that reapplies the active mode). // Arm the new-content detector to re-baseline on its next tick so the just-loaded // project's pre-existing content is treated as the baseline (nothing new) rather // than diffed against the previous project and mass-tagged into the active mode. // A flag (not an inline reset) because detectNewContent owns the baseline and runs // later in the SAME OnTimer tick — it drains this and re-baselines against the live // set in one place, keeping the reset and the observe() adjacent and ordered. g_panel.reloadPending = true; } void bankPanelRefresh() { // New-content auto-tag detection runs EVERY tick regardless of panel open/close: // tracks/items are created in the arrange view, not the panel, so detection must // not be gated on the dock being visible. READ-ONLY on the project; only mutates // the in-memory membership index (persist saves it like any action-driven tag). const bool tagged = detectNewContent(); // Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a // track can only newly become multi-mode when auto-tag placed content on it. Unlike // the invisible membership tag above, minting is a visible structural mutation // (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo // block and only mints for tracks that hold >1 mode's content — a single-mode track // is left to D1 whole-track parking. Managed lanes only; manual lanes untouched. if (tagged && g_panel.session) { ReaProject* proj = EnumProjects(-1, nullptr, 0); mintManagedLanes(g_panel.session->view(), proj); } if (!g_panel.open || !g_panel.hwnd) return; // L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a // toolbar button has rested under the pointer past the delay, latch + repaint the tooltip. maybeShowTooltip(); if (refreshFingerprint()) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } TailSetting bankPanelTailSetting() { // The authoritative setting lives in the session (session->tail()) so it travels // inside the .rpp: it loads per project and saves with the project. This stays the // read seam for the capture actions. manualMs is clamped here so a caller always // receives a within-cap length regardless of what was stored/scrolled. TailSetting s = currentTail(); s.manualMs = clampManualMs(s.manualMs); return s; } BankPanelFullHeight bankPanelFullHeight() { return g_panel.fullHeight; } static void setFullHeight(BankPanelFullHeight target) { g_panel.fullHeight = (g_panel.fullHeight == target) ? BankPanelFullHeight::Split : target; if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } void bankPanelToggledPoolFullHeight() { setFullHeight(BankPanelFullHeight::PoolOnly); } void bankPanelToggledBanksFullHeight() { setFullHeight(BankPanelFullHeight::BanksOnly); } void bankPanelInvalidate() { if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } void bankPanelShutdown() { closePanel(); deinitPreview(); kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1) g_panel.cache.clear(); g_panel.session = nullptr; } } // namespace reasampler