diff --git a/CMakeLists.txt b/CMakeLists.txt index 09e9d7b..6bf216f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1068,7 +1068,14 @@ add_library(reaper_reasampler MODULE src/shell/capture/capture_realtime.cpp src/core/capture/realtime_record.cpp src/persist.cpp - src/bank_panel.cpp + src/shell/panel/panel_audition.cpp + src/shell/panel/panel_bank_ops.cpp + src/shell/panel/panel_drag.cpp + src/shell/panel/panel_input.cpp + src/shell/panel/panel_layout.cpp + src/shell/panel/panel_render.cpp + src/shell/panel/panel_thumbnails.cpp + src/shell/panel/panel_window.cpp src/shell/panel/draw_kit.cpp src/core/view/mode_switch.cpp src/core/ui/tab_strip.cpp diff --git a/src/actions.cpp b/src/actions.cpp index 6f025b3..488d6a0 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -29,7 +29,9 @@ #include "core/version/app_version.h" // channelCommandId / channelActionName — one channel-identity point #include "core/model/bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) -#include "bank_panel.h" // selection seam + full-height toggles (B3/B4) +#include "shell/panel/panel_bank_ops.h" // selection seam (B3/B4) +#include "shell/panel/panel_layout.h" // full-height toggles (B3) +#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint #include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #include "core/view/lane_keys.h" // isOnManualLane — the single managed/manual predicate #include "persist.h" // ReaSamplerSession (owns book() + view() model) diff --git a/src/app/main.cpp b/src/app/main.cpp index 3c5aafb..92f1daf 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -30,7 +30,9 @@ #include "actions.h" #include "core/version/app_version.h" #include "core/model/bank_model.h" -#include "bank_panel.h" +#include "shell/panel/panel_bank_ops.h" // selection read seam (insert action) +#include "shell/panel/panel_input.h" // bankPanelRefresh / project-load notify / tail seam +#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/shutdown) #include "core/capture/batch_capture.h" #include "shell/capture/capture.h" #include "ingest.h" diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp deleted file mode 100644 index 94d6fd2..0000000 --- a/src/bank_panel.cpp +++ /dev/null @@ -1,3460 +0,0 @@ -#include "core/namespaces.h" -// 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 BankModel& / 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 "core/ui/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 "core/ui/drag_out.h" // pure gesture-boundary decision + path-list assembly (M11) -#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) -#include "core/version/app_version.h" // channelCommandId — compose the named-command lookup string (M11) -#include "core/model/bank_book.h" -#include "core/ui/bank_grid.h" -#include "core/model/bank_model.h" -#include "core/ui/card_drag.h" // L7 pure gesture precedence + sparse slot layout/hit-test -#include "core/ui/card_meta.h" // L7 decorative overlay formatters: bars.beats + s.ms (pure) -#include "core/capture/capture_paths.h" -#include "core/ui/component_geometry.h" // KitBox — the kit text()'s draw box (L1) -#include "shell/panel/draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1) -#include "core/ui/footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4) -#include "core/view/guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) -#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest -#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset — the .vstpreset payload (S17) -#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17) -#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) -#include "core/view/lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) -#include "core/ui/mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) -#include "core/view/mode_switch.h" -#include "core/ui/overflow_menu.h" // top-toolbar More-button geometry + reserve (pure, L5) -#include "core/audio/peaks.h" -#include "persist.h" -#include "core/ui/prune_button.h" // footer prune-button layout + hit-test (pure, R3) -#include "core/ui/tooltip.h" // tooltip placement + prefix-strip (pure, L5) -#include "core/capture/render_settings.h" // captureActionTable — the table-driven button rows (M11) -#include "core/ui/tab_strip.h" -#include "core/capture/tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) -#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) -#include "shell/view/view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires -#include "core/view/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 BankModel 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 BankModel* 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 BankModel 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 BankModel* 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 - // BankModel 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 BankModel* 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 -// BankModel position. -void startAudition(int idx) { - stopAudition(); - - const BankModel* 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 BankModel* 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*/BankModel& 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 BankModel& 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 BankModel& 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 BankModel 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 BankModel* 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 BankModel* 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 diff --git a/src/bank_panel.h b/src/bank_panel.h deleted file mode 100644 index 1d5d52b..0000000 --- a/src/bank_panel.h +++ /dev/null @@ -1,131 +0,0 @@ -#pragma once -#include "core/namespaces.h" -// bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns -// a SWELL dialog docked via DockWindowAddEx, and paints the current project's -// bank as a grid of LICE-drawn waveform thumbnails. The panel itself NEVER inserts -// into the arrange or mutates the project/bank (CONTEXT.md §load-bearing -// principle). Audition / multi-select / keyboard nav are Wave B. -// -// The header is REAPER-free as practical: main.cpp drives the panel through these -// free functions, passing the live session so the panel reads the current bank. -// All SWELL / LICE / PCM_source use is confined to bank_panel.cpp. The pure -// layout math and cache keys live in bank_grid (unit-tested outside the DAW). - -#include -#include - -#include "core/capture/tail_control.h" // TailSetting — the panel's tail-mode toggle state - -namespace reasampler { - -class ReaSamplerSession; - -// Wires the panel into main.cpp's lifecycle. Called once after the API pointers -// are loaded, BEFORE the toggle action is registered. `session` must outlive the -// panel (it is the extension-lifetime g_session). Stores the session pointer the -// panel reads on every repaint; does not create the window yet. -void bankPanelInit(ReaSamplerSession* session); - -// Toggles the docked window: creates+docks it if hidden, hides+undocks it if -// shown. Bound to the "toggle bank panel" action. Safe to call before the first -// timer tick. -void bankPanelToggle(); - -// Whether the panel window is currently open/visible. Feeds the action's -// checked-state (toggleaction) so REAPER shows a tick next to the menu entry. -bool bankPanelIsOpen(); - -// The stable ids of the currently-selected samples, in bank (insertion) order. -// Empty when nothing is selected or the panel has never opened. This is the clean -// seam the `insert` action reads to know WHAT to place — it returns ids (not grid -// indices) so the caller resolves against the live bank and is unaffected by the -// panel's internal index bookkeeping. READ of panel state only; no mutation. -// -// Note: the panel's selection is cleared on a bank change (capture / project -// load), so a returned id always names a sample present in the current bank at -// the moment of the call; the caller still tolerates an absent id gracefully. -// -// Phase B4 (vertical split): the selection lives in whichever REGION the user last -// interacted with (the pool grid on top or a named-bank grid below), which is NOT -// necessarily the active/capture-target bank. The returned ids therefore name -// samples in the FOCUSED region's displayed bank — the bank the user visibly -// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those -// ids belong to (the move/copy source). -std::vector bankPanelSelectedSampleIds(); - -// The bank id the current selection belongs to — the displayed bank of the region -// the user last interacted with (pool region -> the pool id; named-banks region -> -// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current -// selection, and it is distinct from the active/capture-target bank (active ≠ shown). -// Returns the pool id when nothing is selected or the panel has never opened (a safe -// default source). READ of panel state only; no mutation. -std::string bankPanelSelectedSourceBankId(); - -// Requests a repaint if the bank changed since the last paint (generation bump). -// Cheap when nothing changed. Driven by the timer so a capture / project load is -// reflected without the panel diffing the bank itself. -void bankPanelRefresh(); - -// Notifies the panel that persist just (re)loaded a project's view model (membership + -// active mode). main.cpp calls this on the exact tick it drains persist's load signal -// and reapplies the active mode. It re-arms the new-content detector so the just-loaded -// project's PRE-EXISTING content is taken as the baseline (reported as nothing new), -// never diffed against the previously-open project and mass-tagged into the active mode. -// This coordinates the detector's project-identity signal with persist's authoritative -// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address, -// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/ -// arm of panel state only; no project or bank mutation. -void bankPanelNotifyProjectLoaded(); - -// The panel's current tail-mode setting (mode + Manual length), read by the plain -// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture -// applies whatever the panel toggle is set to. Default None (exact bounds) — a -// capture with no explicit choice stays byte-identical to today. Extension-session -// setting: persists across project loads and panel open/close within a REAPER session; -// resets to None only when the extension unloads (fresh REAPER session). Project -// persistence across REAPER restarts is a noted follow-on. -// Safe to call before the panel has ever opened (returns the default). READ of panel -// state only; the toggle is mutated by a click inside the panel, never here. -TailSetting bankPanelTailSetting(); - -// The vertical-split full-height layout state (Phase B). The bank window splits -// vertically — pool on top, named-banks region below — and two toggles collapse the -// split: pool full-height (hide the named-banks region) and banks full-height (hide -// the pool). The two are mutually exclusive with the default (both regions shown), -// so one enum captures the whole state. -// -// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives -// here beside the tail setting — the other session-level view-layout bit the panel -// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not -// project state, so it must not travel with the .rpp. In-memory for the extension's -// lifetime; resets to Split on unload. -enum class BankPanelFullHeight { - Split, // default: pool region on top, named-banks region below - PoolOnly, // pool full-height — named-banks region hidden - BanksOnly, // banks full-height — pool region hidden -}; - -// The current full-height layout state (default Split). READ by B4's panel to decide -// which region(s) to draw. Safe before the panel has ever opened. -BankPanelFullHeight bankPanelFullHeight(); - -// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from -// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool -// full-height" action. Requests a repaint so an open panel reflects the change. -void bankPanelToggledPoolFullHeight(); - -// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle. -// Bound to the "banks full-height" action. Requests a repaint. -void bankPanelToggledBanksFullHeight(); - -// Requests an immediate repaint of the panel if it is open. A no-op when the panel -// is closed (safe to call unconditionally). Called by the actions layer after a -// mode change so the footer [Arrange|Design] toggle reflects the new mode without -// requiring a hide/reshow. -void bankPanelInvalidate(); - -// Tears the panel down on extension unload: destroys the window and releases any -// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. -void bankPanelShutdown(); - -} // namespace reasampler diff --git a/src/ingest.cpp b/src/ingest.cpp index 250b201..6496abc 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -21,7 +21,7 @@ #include "core/wire/assignment_request.h" // pure (bankId, sampleId, generation) encode #include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex #include "core/model/bank_model.h" // Sample, AddResult, findByHash -#include "bank_panel.h" // bankPanelRefresh +#include "shell/panel/panel_input.h" // bankPanelRefresh #include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent #include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) diff --git a/src/shell/capture/insert.cpp b/src/shell/capture/insert.cpp index d6344dc..15e5dde 100644 --- a/src/shell/capture/insert.cpp +++ b/src/shell/capture/insert.cpp @@ -38,7 +38,7 @@ #include #include "core/model/bank_model.h" -#include "bank_panel.h" +#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId #include "core/capture/capture_paths.h" #include "persist.h" diff --git a/src/shell/panel/panel_audition.cpp b/src/shell/panel/panel_audition.cpp new file mode 100644 index 0000000..d05136f --- /dev/null +++ b/src/shell/panel/panel_audition.cpp @@ -0,0 +1,117 @@ +// panel_audition.cpp — the audition/preview engine seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; M5 Wave B). HOT PATH GUARDRAIL (T4-28 / Q-W2): the +// preview path stays a DIRECT free-function call-through — no interface, no virtual +// dispatch, no added header->TU indirection; the idle path is unchanged in shape. +// +// 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). DAW-verified, not unit tested. + +#include + +#include "shell/panel/panel_state.h" + +// 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_MINIMAL +#define REAPERAPI_WANT_PlayPreview +#define REAPERAPI_WANT_StopPreview +#define REAPERAPI_WANT_PCM_Source_CreateFromFile +#define REAPERAPI_WANT_PCM_Source_Destroy +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +// --- 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 +// BankModel position. +void startAudition(int idx) { + stopAudition(); + + const BankModel* 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; + } +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp new file mode 100644 index 0000000..621eb04 --- /dev/null +++ b/src/shell/panel/panel_bank_ops.cpp @@ -0,0 +1,490 @@ +// panel_bank_ops.cpp — the bank-CRUD + menus seam of the docked bank panel (Q-W2 +// split of bank_panel.cpp; Phase B4/B5). The SINGLE home of the panel-side bank verbs +// (create / rename / delete / evacuate / activate / move / copy / remove) — the owner +// Q-W4 dedupes actions.cpp against — plus the book/bank accessors, the popup menus +// that drive them, and the selection-id / OS-drag path resolvers. +// +// Each op mutates g_session.book() then persists via persistBankOp() (one bank op = +// one Ctrl-Z; a true index no-op opens NO undo point). It DOES mutate the bank BOOK — +// 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). REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL +// mutation any Bank*/BankModel& is invalid — resolve fresh, pass ids. +// +// 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). DAW-verified, not unit tested. + +#include +#include +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_bank_ops.h" + +#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) +#include "persist.h" // ReaSamplerSession — the live session the ops mutate + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetUserInputs +#define REAPERAPI_WANT_ShowMessageBox +#define REAPERAPI_WANT_Main_OnCommand +#define REAPERAPI_WANT_genGuid +#define REAPERAPI_WANT_guidToString +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +namespace fs = std::filesystem; + +// --- 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 BankModel 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 BankModel* 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; +} + +// --- 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*/BankModel& 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). + +namespace { + +// 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); +} + +} // namespace + +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(); +} + +namespace { + +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(); +} + +} // namespace + +// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass +// ids straight to the model op (no BankModel& 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 BankModel& 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 BankModel 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 BankModel* 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. + +namespace { + +// 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 +}; + +} // namespace + +// 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); + } +} + +} // namespace reasampler::panel + +// --- Public API (the selection read seam — panel_bank_ops.h) ------------------- + +namespace reasampler { + +std::vector bankPanelSelectedSampleIds() { + return panel::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 (panel::g_panel.selection.empty()) return std::string(kPoolBankId); + const std::string id = panel::bankIdForRegion(panel::g_panel.focusedRegion); + return id.empty() ? std::string(kPoolBankId) : id; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h new file mode 100644 index 0000000..6853129 --- /dev/null +++ b/src/shell/panel/panel_bank_ops.h @@ -0,0 +1,43 @@ +#pragma once +// panel_bank_ops — the bank-CRUD + selection-read seam of the bank panel (Q-W2 split +// of bank_panel.h; Phase B4/B5). The .cpp is the single home of the panel-side bank +// verbs (create / rename / delete / evacuate / activate / move / copy / remove), +// each driven against the B1 BankBook model on the session and persisted via +// persistBankOp (one bank op = one Ctrl-Z) — the owner Q-W4 dedupes actions.cpp +// against. This header carries the panel's public selection-read surface. +// +// REAPER-free: main.cpp (insert action) and actions.cpp read the selection through +// these free functions. + +#include +#include + +namespace reasampler { + +// The stable ids of the currently-selected samples, in bank (insertion) order. +// Empty when nothing is selected or the panel has never opened. This is the clean +// seam the `insert` action reads to know WHAT to place — it returns ids (not grid +// indices) so the caller resolves against the live bank and is unaffected by the +// panel's internal index bookkeeping. READ of panel state only; no mutation. +// +// Note: the panel's selection is cleared on a bank change (capture / project +// load), so a returned id always names a sample present in the current bank at +// the moment of the call; the caller still tolerates an absent id gracefully. +// +// Phase B4 (vertical split): the selection lives in whichever REGION the user last +// interacted with (the pool grid on top or a named-bank grid below), which is NOT +// necessarily the active/capture-target bank. The returned ids therefore name +// samples in the FOCUSED region's displayed bank — the bank the user visibly +// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those +// ids belong to (the move/copy source). +std::vector bankPanelSelectedSampleIds(); + +// The bank id the current selection belongs to — the displayed bank of the region +// the user last interacted with (pool region -> the pool id; named-banks region -> +// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current +// selection, and it is distinct from the active/capture-target bank (active ≠ shown). +// Returns the pool id when nothing is selected or the panel has never opened (a safe +// default source). READ of panel state only; no mutation. +std::string bankPanelSelectedSourceBankId(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp new file mode 100644 index 0000000..68fb38e --- /dev/null +++ b/src/shell/panel/panel_drag.cpp @@ -0,0 +1,511 @@ +// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; the T4-01 NEW seam; M11/L7/S17). Owns WM_MOUSEMOVE +// (hover resolution + tooltip timing + the live drag), the drop-target/gesture +// classification, the cursor cues, button-up drop dispatch (reorder / replace / +// move / copy / instrument-drop / OS drag-out), and right-click menu routing. Its +// PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test) with +// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only +// the live rects, modifier state, and side effects. +// +// PER-MOUSE-MOVE GUARDRAIL (T4-28): everything on the move path stays plain +// free-function calls — no interface, no virtual dispatch. +// +// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called +// directly here (the FX-hotspot / OS-drag / instrument-drop shells own theirs); +// REAPER SDK types arrive via panel_state.h. + +#include // std::abs (drag threshold) +#include +#include + +#include "shell/panel/panel_state.h" + +#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B) +#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) +#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop (S17) + +namespace reasampler::panel { + +// --- Drag (move between regions/onto a tab) ----------------------------------- + +constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag + +namespace { + +// 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(); + } +} + +} // namespace + +// 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. +namespace { + +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(); +} + +} // namespace + +// 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 BankModel* 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); + } +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp new file mode 100644 index 0000000..5163172 --- /dev/null +++ b/src/shell/panel/panel_input.cpp @@ -0,0 +1,636 @@ +// panel_input.cpp — the input + detection seam of the docked bank panel (Q-W2 split +// of bank_panel.cpp). Owns left-click / wheel / keyboard routing (plain free-function +// calls on the per-event path — T4-28), the accelerator registration, the tail-setting +// read/mutate helpers, and the timer-driven new-content auto-tag detection (D2 Wave 2). +// Mouse-MOVE (hover + the card-drag state machine) lives in panel_drag; the bank-change +// fingerprint pass lives in panel_thumbnails (it owns the cache it invalidates). +// +// 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). DAW-verified, not unit tested. + +#include +#include +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_input.h" + +#include "actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) +#include "persist.h" // ReaSamplerSession — view/tail reads + mutation +#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2) +#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) +#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) +#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation (D2/D4) + +// 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_MINIMAL +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_MarkProjectDirty +#define REAPERAPI_WANT_Main_OnCommand +#include "reaper_plugin_functions.h" + +// main.cpp owns REAPER's dispatch struct (the accelerator registers through it). +extern reaper_plugin_info_t* g_rec; + +namespace reasampler::panel { + +// 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{}; +} + +namespace { + +// 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); +} + +// 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; +} + +// --- 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 +// panel_input.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). panel_input.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(); +} + +// 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 BankModel* idx = indexForRegion(g_panel.focusedRegion); + return idx ? static_cast(idx->size()) : 0; +} + +// --- 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; +} + +} // namespace + +// 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; +} + +namespace { + +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; + +} // namespace + +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; +} + +} // namespace reasampler::panel + +// --- Public API (the timer + tail read seam — panel_input.h) ------------------- + +namespace reasampler { + +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. + panel::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 = panel::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 && panel::g_panel.session) { + ReaProject* proj = EnumProjects(-1, nullptr, 0); + mintManagedLanes(panel::g_panel.session->view(), proj); + } + + if (!panel::g_panel.open || !panel::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. + panel::maybeShowTooltip(); + + if (panel::refreshFingerprint()) + InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +capture::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. + capture::TailSetting s = panel::currentTail(); + s.manualMs = capture::clampManualMs(s.manualMs); + return s; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_input.h b/src/shell/panel/panel_input.h new file mode 100644 index 0000000..9f08280 --- /dev/null +++ b/src/shell/panel/panel_input.h @@ -0,0 +1,43 @@ +#pragma once +// panel_input — the input + detection seam of the bank panel (Q-W2 split of +// bank_panel.h). The .cpp owns mouse-click / wheel / keyboard routing (plain +// free-function calls per T4-28 — no interface on the per-event path) plus the +// timer-driven detection passes: new-content auto-tag (D2 Wave 2) and the +// hover-delay tooltip latch. This header carries the timer/lifecycle surface +// main.cpp drives and the tail-setting read seam the capture actions consume. +// +// REAPER-free as practical: TailSetting is the pure capture-side type. + +#include "core/capture/tail_control.h" // capture::TailSetting — the panel's tail-mode toggle state + +namespace reasampler { + +// Requests a repaint if the bank changed since the last paint (generation bump). +// Cheap when nothing changed. Driven by the timer so a capture / project load is +// reflected without the panel diffing the bank itself. Also hosts the every-tick +// new-content auto-tag detection (runs whether or not the dock is visible) and the +// tooltip hover-delay latch. +void bankPanelRefresh(); + +// Notifies the panel that persist just (re)loaded a project's view model (membership + +// active mode). main.cpp calls this on the exact tick it drains persist's load signal +// and reapplies the active mode. It re-arms the new-content detector so the just-loaded +// project's PRE-EXISTING content is taken as the baseline (reported as nothing new), +// never diffed against the previously-open project and mass-tagged into the active mode. +// This coordinates the detector's project-identity signal with persist's authoritative +// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address, +// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/ +// arm of panel state only; no project or bank mutation. +void bankPanelNotifyProjectLoaded(); + +// The panel's current tail-mode setting (mode + Manual length), read by the plain +// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture +// applies whatever the panel toggle is set to. Default None (exact bounds) — a +// capture with no explicit choice stays byte-identical to today. The authoritative +// setting lives in ReaSamplerSession (it travels inside the .rpp); the panel mutates +// it via the footer Tail button (cycle) and scroll-wheel (Manual fine-adjust), both +// owned by this input seam. Safe to call before the panel has ever opened (returns +// the default). READ of panel state only. +capture::TailSetting bankPanelTailSetting(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp new file mode 100644 index 0000000..320c644 --- /dev/null +++ b/src/shell/panel/panel_layout.cpp @@ -0,0 +1,570 @@ +// panel_layout.cpp — the geometry-glue seam of the docked bank panel (Q-W2 split of +// bank_panel.cpp; the T4-01 NEW seam). Owns the toolbar/footer/menu rects, the +// toolbar row/cluster builders, the vertical-split geometry + region rects, and the +// L7 slot-order display bridge (regionDisplay/focusedDisplay). Every rect is derived +// from the client size + fullHeight state, and BOTH paint (panel_render) and +// hit-testing (panel_input / panel_drag) call these so they never drift. +// +// Also home of the public split-state seam (panel_layout.h): the B3-owned +// BankPanelFullHeight toggles the render derives the region rects from. +// +// 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). DAW-verified, not unit tested (the PURE tiling / +// hit-test math lives in action_bar / footer_bar / prune_button / overflow_menu / +// tab_strip / mode_switch / bank_grid / card_drag, unit-tested outside the DAW). + +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_layout.h" + +#include "persist.h" // ReaSamplerSession — mode/view reads +#include "core/view/view_mode_model.h" // ViewModeModel — modes()/activeModeId() + +// Action-trigger buttons (M11): resolve each button's command id at runtime from the +// composed named-command string and read its current key binding for the tooltip. +// All main-section (SectionFromUniqueID(0)). +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_NamedCommandLookup +#define REAPERAPI_WANT_kbd_getTextFromCmd +#define REAPERAPI_WANT_SectionFromUniqueID +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +// --- 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). + +namespace { + +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). +} // namespace + +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 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 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{}); +} + +// 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; +} + +// === 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. + +// 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}, + }; +} + +namespace { + +// 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(); +} + +} // namespace + +// 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 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()); +} + +namespace { + +// 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 {}; +} + +} // namespace + +// 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); +} + +// --- 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 lives in panel_render (drawTooltip); 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; +} + +// --- 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; +} + +// 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); +} + +// 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; +} + +// 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()); +} + +// 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); +} + +} // namespace reasampler::panel + +// --- Public API (the split-state seam — panel_layout.h) ------------------------ + +namespace reasampler { + +BankPanelFullHeight bankPanelFullHeight() { + return panel::g_panel.fullHeight; +} + +static void setFullHeight(BankPanelFullHeight target) { + panel::g_panel.fullHeight = + (panel::g_panel.fullHeight == target) ? BankPanelFullHeight::Split : target; + if (panel::g_panel.hwnd) InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +void bankPanelToggledPoolFullHeight() { + setFullHeight(BankPanelFullHeight::PoolOnly); +} + +void bankPanelToggledBanksFullHeight() { + setFullHeight(BankPanelFullHeight::BanksOnly); +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_layout.h b/src/shell/panel/panel_layout.h new file mode 100644 index 0000000..ef81c1a --- /dev/null +++ b/src/shell/panel/panel_layout.h @@ -0,0 +1,43 @@ +#pragma once +// panel_layout — the vertical-split layout STATE seam of the bank panel (Q-W2 split of +// bank_panel.h; Phase B3/B4). The panel window splits vertically — pool on top, +// named-banks region below — and two toggles collapse the split. This header carries +// that public state surface; the geometry derivation itself (toolbar/footer/menu rects, +// row/cluster builders, region rects, the L7 slot-order display bridge) is internal to +// panel_layout.cpp (see panel_state.h for the intra-panel seam). +// +// REAPER-free: main.cpp / actions.cpp drive these through plain free functions. + +namespace reasampler { + +// The vertical-split full-height layout state (Phase B). The bank window splits +// vertically — pool on top, named-banks region below — and two toggles collapse the +// split: pool full-height (hide the named-banks region) and banks full-height (hide +// the pool). The two are mutually exclusive with the default (both regions shown), +// so one enum captures the whole state. +// +// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives +// beside the tail setting — the other session-level view-layout bit the panel +// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not +// project state, so it must not travel with the .rpp. In-memory for the extension's +// lifetime; resets to Split on unload. +enum class BankPanelFullHeight { + Split, // default: pool region on top, named-banks region below + PoolOnly, // pool full-height — named-banks region hidden + BanksOnly, // banks full-height — pool region hidden +}; + +// The current full-height layout state (default Split). READ by B4's panel to decide +// which region(s) to draw. Safe before the panel has ever opened. +BankPanelFullHeight bankPanelFullHeight(); + +// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from +// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool +// full-height" action. Requests a repaint so an open panel reflects the change. +void bankPanelToggledPoolFullHeight(); + +// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle. +// Bound to the "banks full-height" action. Requests a repaint. +void bankPanelToggledBanksFullHeight(); + +} // namespace reasampler diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp new file mode 100644 index 0000000..04e44d6 --- /dev/null +++ b/src/shell/panel/panel_render.cpp @@ -0,0 +1,613 @@ +// panel_render.cpp — the LICE draw seam of the docked bank panel (Q-W2 split of +// bank_panel.cpp; M5 Wave A/B + Phase B4 + Phase L). Owns WM_PAINT's full paint: +// the VERTICAL SPLIT (pool grid region on top, named-banks tab-page region below), +// the region headers + tab strip, the two task-grouped toolbars + More button, the +// footer (mode toggle + count + Tail + Prune), the hover-delay tooltip overlay, and +// the per-card thumbnail/metadata draw — everything through the L1 kit by palette +// role (draw_kit), double-buffered, BitBlt'd once. +// +// READ-ONLY: reads panel + session state; the input/drag seams mutate it. All rect +// derivation comes from panel_layout (the single source both draw and hit-test use). +// +// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called +// here (LICE/Win32 only); REAPER SDK types arrive via panel_state.h. + +#include +#include + +#include "shell/panel/panel_state.h" + +#include "shell/panel/draw_kit.h" // kit text()/fillSurface/drawButton/drawWaveform (L1) +#include "persist.h" // ReaSamplerSession — mode/view/tail reads +#include "core/view/view_mode_model.h" // ViewModeModel / Mode — the footer toggle's model + +namespace reasampler::panel { + +namespace { + +// --- 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); +} + +// 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; +} + +// 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}, + 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); + } +} + +// 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); + } +} + +// --- 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); +} + +// 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); +} + +// --- 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 BankModel* 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 + // BankModel 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); +} + +} // namespace + +// --- 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); +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h new file mode 100644 index 0000000..e328222 --- /dev/null +++ b/src/shell/panel/panel_state.h @@ -0,0 +1,608 @@ +#pragma once +// panel_state — INTERNAL shared state + cross-seam contract of the docked bank panel +// (Q-W2: bank_panel.cpp split into eight TUs under shell/panel/). Included ONLY by the +// panel's own translation units (panel_render / panel_thumbnails / panel_audition / +// panel_input / panel_layout / panel_drag / panel_bank_ops / panel_window) — consumers +// outside the panel use the per-seam public headers (panel_window.h / panel_input.h / +// panel_bank_ops.h / panel_layout.h). +// +// What lives here: +// * PanelState (the one shared state blob, defined in panel_window.cpp) + the small +// enums/structs the seams speak (Region / DropKind / Hover / RegionDisplay / +// ActionBarRow) and the shared layout constants. +// * The cross-seam free-function declarations, grouped by OWNING TU. Everything is a +// plain free function — direct call-through, no interface, no virtual dispatch +// (T4-28: the audition path and the per-mouse-move path must stay direct calls). +// * Explicit using-declarations pulling the pure modules' symbols into +// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces) — this +// header itself does not directly include the interim core/namespaces.h shim +// (Q-W2 retires that direct dependency for this module). Six of the eight panel +// TUs still pull the shim in TRANSITIVELY via actions.h/persist.h/ingest.h/ +// draw_kit.h/view.h; only panel_thumbnails.cpp and panel_audition.cpp are +// shim-free end to end. Nothing HERE depends on it either way. +// +// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural +// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, +// so a BankModel& / Bank* must NEVER be cached across one. Every seam resolves fresh +// AFTER any mutation and passes bank IDS (not references) into the model ops. + +#include +#include +#include +#include + +// SWELL / platform types (HWND, RECT, HMENU). 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 +#else +#include +#endif +#include "wdltypes.h" +#include "swell/swell.h" + +// REAPER SDK types only (preview_register_t, MediaTrack, ReaProject). The API function +// POINTERS are declared per-TU (REAPERAPI_MINIMAL + per-TU WANT list) — main.cpp owns +// the definitions (CLAUDE.md §contract). +#include "reaper_plugin.h" + +#include "core/audio/peaks.h" // audio::Envelope — thumbnail cache payload +#include "core/capture/capture_paths.h" // capture::resolveBankFile / normalizeSlashes +#include "core/capture/render_settings.h" // capture::CaptureActionDef / captureActionTable +#include "core/capture/tail_control.h" // capture::TailSetting — the tail toggle state +#include "core/model/bank_book.h" // BankBook / Bank / SlotMap (flat reasampler until its split wave) +#include "core/model/bank_model.h" // model::BankModel / model::Sample +#include "core/ui/action_bar.h" // ui::ActionBarRect / slots / clusters +#include "core/ui/bank_grid.h" // ui::GridSpec / Selection / ThumbnailKey / CellRect +#include "core/ui/card_drag.h" // ui::CardGesture / SlotCellRect / gesture decisions +#include "core/ui/card_meta.h" // ui::MusicalLength / formatters +#include "core/ui/component_geometry.h" // ui::KitBox / KitButtonBox / waveformColumnCount +#include "core/ui/drag_out.h" // ui::DragState / PanelClientRect / decideGesture +#include "core/ui/footer_bar.h" // ui::FooterBarLayout / computeFooterBar +#include "core/ui/mode_enable.h" // ui::tagButtonEnabled / TagTarget +#include "core/ui/overflow_menu.h" // ui::MenuButtonSpec / computeMenuButton +#include "core/ui/prune_button.h" // ui::ButtonRect / computePruneButton +#include "core/ui/tab_strip.h" // ui::TabStripSpec / layout / hit-test +#include "core/ui/theme.h" // ui::Role / InteractionState / KitColor +#include "core/ui/tooltip.h" // ui::TooltipBox / computeTooltip / stripActionPrefix +#include "core/version/app_version.h" // version::channelCommandId / appVersion / dock identity +#include "core/view/guid_diff.h" // view::GuidBaseline — new-content detection +#include "core/view/lane_keys.h" // view::isOnManualLane +#include "core/view/mode_switch.h" // view::SegmentRect / computeSegmentRects +#include "core/wire/instrument_drop.h" // wire::buildInstrumentDropPreset (S17) + +#include "shell/panel/panel_layout.h" // BankPanelFullHeight — the split-state enum + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::panel { + +// --- Real-namespace-home using-declarations ----------------------------------- +// +// The panel's pre-split internals reference the pure modules' symbols unqualified; +// these explicit per-symbol usings (NOT the core/namespaces.h shim) keep those +// references valid while documenting each symbol's Q-W1 home. Flat-`reasampler` +// symbols (BankBook / ViewModeModel / the draw_kit shell / persistBankOp / ...) +// resolve via the enclosing namespace and need no using. + +// core/ui +using ui::ActionBarRect; +using ui::ActionBarSlot; +using ui::ActionBarSpec; +using ui::ActionCluster; +using ui::ButtonRect; +using ui::CardGesture; +using ui::CellRect; +using ui::ClusterSpec; +using ui::CursorCue; +using ui::DragGesture; +using ui::DragModifiers; +using ui::DragState; +using ui::DropRegion; +using ui::FooterBarLayout; +using ui::FooterBarSpec; +using ui::FooterHit; +using ui::FooterRect; +using ui::GridSpec; +using ui::InteractionState; +using ui::KitBox; +using ui::KitButtonBox; +using ui::KitColor; +using ui::MenuBarRect; +using ui::MenuButtonRect; +using ui::MenuButtonSpec; +using ui::MusicalLength; +using ui::NavKey; +using ui::PanelClientRect; +using ui::PruneButtonSpec; +using ui::ResolvedSample; +using ui::Role; +using ui::Selection; +using ui::SlotCellRect; +using ui::TabHit; +using ui::TabHitKind; +using ui::TabRect; +using ui::TabStripLayout; +using ui::TabStripRect; +using ui::TabStripSpec; +using ui::TagTarget; +using ui::ThumbnailKey; +using ui::TooltipBox; +using ui::TooltipSpec; +using ui::applyClick; +using ui::assemblePathList; +using ui::clampTabScroll; +using ui::columnsForWidth; +using ui::computeBarSlots; +using ui::computeFooterBar; +using ui::computeMenuButton; +using ui::computePruneButton; +using ui::computeSlotRects; +using ui::computeSlotRectsForDrop; +using ui::computeTabRects; +using ui::computeTabStripLayout; +using ui::computeTooltip; +using ui::cursorForGesture; +using ui::decideCardGesture; +using ui::decideGesture; +using ui::formatBarsBeats; +using ui::formatSecondsMs; +using ui::hitTestActionBar; +using ui::hitTestFooterBar; +using ui::hitTestMenuButton; +using ui::hitTestPruneButton; +using ui::hitTestSlot; +using ui::hitTestTabStrip; +using ui::menuButtonReserve; +using ui::navigate; +using ui::roleColor; +using ui::stripActionPrefix; +using ui::tagButtonEnabled; +using ui::thumbnailKeyString; +using ui::waveformColumnCount; + +// core/view +using view::GuidBaseline; +using view::HeaderRect; +using view::SegmentRect; +using view::computeSegmentRects; +using view::hitTestSegment; +using view::isOnManualLane; + +// core/capture +using capture::CaptureActionDef; +using capture::TailMode; +using capture::TailSetting; +using capture::adjustManualMs; +using capture::captureActionTable; +using capture::clampManualMs; +using capture::cycleTailMode; +using capture::kManualStepMs; +using capture::normalizeSlashes; +using capture::resolveBankFile; +using capture::tailToggleLabel; + +// core/audio +using audio::Envelope; +using audio::computeEnvelope; + +// core/model +using model::BankModel; +using model::Sample; + +// core/version +using version::actionDisplayPrefix; +using version::appVersion; +using version::channelCommandId; +using version::dockIdent; +using version::dockTitle; + +// core/wire +using wire::buildInstrumentDropPreset; + +// --- Layout constants --------------------------------------------------------- +// +// L2: every panel COLOR comes from the pure `theme` module by ROLE (drawn through the +// L1 kit — fillSurface / drawButton / kit text). Only the pixel LAYOUT metrics (band +// heights, grid/tab specs, insets) live here, shared by the layout/render/input/drag +// seams so draw and hit-test can never drift. + +inline const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; + +// --- Footer (Phase L, L4) ----------------------------------------------------- +// The footer 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. +inline 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. +inline constexpr int kTopToolbarHeight = 28; // single-row label face (L6: keybinding sub-row removed) +inline 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). +inline constexpr unsigned int kTooltipDelayMs = 500; +inline constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous) +inline 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. +inline constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band +inline constexpr int kTabStripHeight = 26; // the named-banks tab strip band +inline constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions +inline constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button +inline constexpr int kCreateBtnWidth = 22; // the "+" create-bank button + +// Tab strip metrics (the pure tab_strip owns the math; these are its inputs). +inline const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; + +// 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 (L5). +inline const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6, + /*verticalInset=*/3, /*minLeftInset=*/40}; + +// 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. +inline const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24, + /*sidePad=*/8, /*verticalInset=*/3}; + +// --- 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. +inline 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 handleClick/ + // 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 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 +}; + +// The one shared panel state blob. Defined in panel_window.cpp (the lifecycle owner). +extern PanelState g_panel; + +// --- 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 BankModel insertion order. regionDisplay +// (panel_layout.cpp) 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()); } +}; + +// --- Toolbar row vocabulary (Phase L, L4/L5/L6) -------------------------------- +// +// 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. Built by panel_layout (topBarRows / bottomBarRows / +// overflowMenuRows); consumed by the render draw, the input click routing, and the drag hover. +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). +}; + +// --- Shared one-liner helpers -------------------------------------------------- + +// Modifier state at event time. Alt = the L7 replace modifier. +inline bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } +inline bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } +inline bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; } + +inline void invalidatePanel() { + if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); +} + +// --- Cross-seam contract (grouped by OWNING TU; all plain free functions) ------ + +// panel_bank_ops.cpp — book/bank accessors + the bank-CRUD verbs + menus. +BankBook* book(); +const BankModel* indexForRegion(Region r); +std::string bankIdForRegion(Region r); +std::vector namedBanks(); +std::string currentProjectDir(); +void doCreateBank(); +void transferSamples(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy); +void removeSamples(const std::vector& sampleIds, + const std::string& srcBankId); +std::vector focusedSelectionIds(); +std::vector resolveDragPathsForOs(); +void showTabMenu(int screenX, int screenY, const std::string& bankId); +void showSelectionMenu(int screenX, int screenY); +void showMoreMenu(); + +// panel_layout.cpp — toolbar/footer/menu rects, row/cluster builders, split geometry, +// region rects, the L7 display bridge. Draw and hit-test both call these so they never drift. +int modeCount(); +MenuButtonRect topMenuButtonRect(int w); +ActionBarRect topToolbarActionRect(int w); +RECT panelFooter(int w, int h); +FooterBarLayout footerBarLayoutFor(int w, int h); +ButtonRect pruneButtonRectFor(int w, int h); +bool pointInFooter(int x, int y); +ActionBarRect bottomToolbarRect(int w, int h); +RECT splitBody(int w, int h); +bool poolShown(); +bool banksShown(); +RECT poolRegionRect(int w, int h); +RECT banksRegionRect(int w, int h); +RECT regionHeaderRect(const RECT& region); +TabStripRect banksTabStripRect(const RECT& region); +RECT regionGridRect(const RECT& region, bool isBanks); +RECT fullHtBtnRect(const RECT& region); +RECT createBtnRect(const RECT& region); +RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg); +RegionDisplay focusedDisplay(); +bool regionAt(int x, int y, Region& out); +int footerToggleSegmentHit(int x, int y, int w, int h); +int columnsForRegion(Region reg); +std::vector topBarRows(); +std::vector bottomBarRows(); +std::vector overflowMenuRows(); +std::vector actionBarClusters(const std::vector& rows); +int resolveBarCommandId(const ActionBarRow& row); +int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows); +bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah); + +// panel_render.cpp — the full LICE paint (drawn last-to-front into the caller's +// double buffer; BitBlt'd once by paintPanel). +void paintPanel(HWND hwnd, HDC hdc); + +// panel_thumbnails.cpp — thumbnail compute + cache, and the bank-change fingerprint +// pass that owns the cache's generation key (bumps generation, clears the cache, and +// reconciles selection/shown-bank on any book mutation). +const Envelope& thumbnailFor(const Sample& sample, int width, const std::string& projectDir); +bool refreshFingerprint(); +void reconcileShownBank(); + +// panel_audition.cpp — the preview engine (HOT PATH: direct call-through, never +// virtual, no added header->TU indirection — T4-28 / Q-W2 guardrail). +void initPreview(); +void deinitPreview(); +void stopAudition(); +void startAudition(int idx); + +// panel_input.cpp — click/wheel/key routing, accelerator, new-content detection, +// and the session-tail read/mutate helpers. +TailSetting currentTail(); +void handleClick(int x, int y); +bool handleWheel(int x, int y, int delta); +void registerAccel(); +void unregisterAccel(); + +// panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag). +// Per-mouse-move work stays plain free-function calls (T4-28). +void onMouseMove(int x, int y); +void onLBtnUp(int x, int y); +void handleRightClick(int x, int y); +void resetDragState(); +void maybeShowTooltip(); + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_thumbnails.cpp b/src/shell/panel/panel_thumbnails.cpp new file mode 100644 index 0000000..65bc1f5 --- /dev/null +++ b/src/shell/panel/panel_thumbnails.cpp @@ -0,0 +1,163 @@ +// panel_thumbnails.cpp — thumbnail compute + cache seam of the docked bank panel +// (Q-W2 split of bank_panel.cpp; M5/FA3). Owns the per-sample PCM read via PCM_source +// fed to peaks::computeEnvelope (one bin per drawn pixel column) and the in-memory +// thumbnail cache keyed by (sample id, bin width, bank generation) — plus the +// bank-change fingerprint pass that OWNS that generation key: refreshFingerprint bumps +// the generation, clears the cache, resets selection/audition, and reconciles the +// shown bank on any book mutation. (The fingerprint pass lives here rather than in +// panel_input because the cache + generation it invalidates are this seam's state — +// a Q-W2 placement judgment; the T4-01 audit lumped it under the input seam's range.) +// +// 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). DAW-verified, not unit tested. + +#include +#include +#include + +#include "shell/panel/panel_state.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_PCM_Source_CreateFromFile +#define REAPERAPI_WANT_PCM_Source_Destroy +#include "reaper_plugin_functions.h" + +namespace reasampler::panel { + +namespace { + +constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) + +// --- 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)); +} + +} // namespace + +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; +} + +// --- Bank-change detection ---------------------------------------------------- + +namespace { + +// 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; +} + +} // namespace + +// 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 BankModel* idx = indexForRegion(g_panel.focusedRegion); + g_panel.selItemCount = idx ? static_cast(idx->size()) : 0; + return true; +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_window.cpp b/src/shell/panel/panel_window.cpp new file mode 100644 index 0000000..d64a9b4 --- /dev/null +++ b/src/shell/panel/panel_window.cpp @@ -0,0 +1,247 @@ +// panel_window.cpp — the window-lifecycle seam of the docked bank panel (Q-W2 split +// of bank_panel.cpp; M5 Wave A). Owns the SWELL dialog (IDD_BANK_PANEL) docked via +// DockWindowAddEx / undocked via DockWindowRemove, the dialog proc that routes +// messages to the input/drag/render/audition seams, the S8 OS drop-target opt-in +// (WM_DROPFILES -> ingest), and the shared PanelState blob's definition. +// +// READ-ONLY of the TIMELINE (load-bearing principle): the panel never inserts into +// the arrange. Channel-qualified dock identity (Phase V, V4): title + persisted- +// position identstr both come from app_version. +// +// 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). DAW-verified, not unit tested. + +#include +#include + +#include "shell/panel/panel_state.h" +#include "shell/panel/panel_window.h" + +#include "shell/panel/draw_kit.h" // kitFontsInit/Shutdown — the kit's cached AA fonts (L1) +#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest + +#ifdef _WIN32 +#include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) +#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest +#endif + +#include "resource.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_DockWindowAddEx +#define REAPERAPI_WANT_DockWindowActivate +#define REAPERAPI_WANT_DockWindowRemove +#define REAPERAPI_WANT_GetMainHwnd +#include "reaper_plugin_functions.h" + +// main.cpp owns the module instance handle. +extern REAPER_PLUGIN_HINSTANCE g_hInst; + +namespace reasampler::panel { + +// The one shared panel state blob (declared extern in panel_state.h). Defined here — +// the lifecycle seam owns the state's lifetime, mirroring the old single-TU global. +PanelState g_panel; + +// --- Dialog proc + docking ---------------------------------------------------- + +namespace { + +// 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 + +} // namespace reasampler::panel + +// --- Public API (the lifecycle seam — panel_window.h) -------------------------- + +namespace reasampler { + +void bankPanelInit(ReaSamplerSession* session) { + panel::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 panel::g_panel.hwnd && IsWindowVisible(panel::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()) + panel::closePanel(); + else + panel::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(); +} + +void bankPanelInvalidate() { + if (panel::g_panel.hwnd) InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); +} + +void bankPanelShutdown() { + panel::closePanel(); + panel::deinitPreview(); + kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1) + panel::g_panel.cache.clear(); + panel::g_panel.session = nullptr; +} + +} // namespace reasampler diff --git a/src/shell/panel/panel_window.h b/src/shell/panel/panel_window.h new file mode 100644 index 0000000..654727c --- /dev/null +++ b/src/shell/panel/panel_window.h @@ -0,0 +1,42 @@ +#pragma once +// panel_window — the window-lifecycle seam of the docked bank panel (Q-W2 split of +// bank_panel.h; M5, Wave A). REAPER-facing shell: the .cpp owns a SWELL dialog +// (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via DockWindowRemove, +// toggled open/closed, plus the OS drop-target opt-in (S8) and the dialog proc that +// routes messages to the input/drag/render seams. The panel itself NEVER inserts +// into the arrange or mutates the project (CONTEXT.md §load-bearing principle). +// +// The header is REAPER-free: main.cpp drives the panel through these free functions, +// passing the live session so the panel reads the current bank. All SWELL / LICE / +// PCM_source use is confined to the shell/panel/ .cpp seams. + +namespace reasampler { + +class ReaSamplerSession; + +// Wires the panel into main.cpp's lifecycle. Called once after the API pointers +// are loaded, BEFORE the toggle action is registered. `session` must outlive the +// panel (it is the extension-lifetime g_session). Stores the session pointer the +// panel reads on every repaint; does not create the window yet. +void bankPanelInit(ReaSamplerSession* session); + +// Toggles the docked window: creates+docks it if hidden, hides+undocks it if +// shown. Bound to the "toggle bank panel" action. Safe to call before the first +// timer tick. +void bankPanelToggle(); + +// Whether the panel window is currently open/visible. Feeds the action's +// checked-state (toggleaction) so REAPER shows a tick next to the menu entry. +bool bankPanelIsOpen(); + +// Requests an immediate repaint of the panel if it is open. A no-op when the panel +// is closed (safe to call unconditionally). Called by the actions layer after a +// mode change so the footer [Arrange|Design] toggle reflects the new mode without +// requiring a hide/reshow. +void bankPanelInvalidate(); + +// Tears the panel down on extension unload: destroys the window and releases any +// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. +void bankPanelShutdown(); + +} // namespace reasampler