8f4a84ac79
Top toolbar = capture+placement, bottom toolbar = Design-View verbs, footer = narrow Arrange|Design toggle + count + Tail button + set-apart Prune. New pure footer_bar module; Tail is now a real kit button.
2661 lines
123 KiB
C++
2661 lines
123 KiB
C++
// 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 at cell width.
|
|
// * an in-memory thumbnail cache keyed by (sample id, draw width, bank generation).
|
|
// * id-keyed bank management (create / rename / delete / evacuate / activate) and
|
|
// sample move/copy — driven from a tab context menu and a drag — against the B1
|
|
// BankBook model on g_session.book(), persisted via g_session.saveToActiveProject().
|
|
//
|
|
// READ-ONLY of the TIMELINE (load-bearing principle): this panel never inserts into
|
|
// the arrange. It DOES mutate the bank BOOK (create/rename/move/etc.) — that is the
|
|
// whole point of B4 — but only the index/model + ext-state, never the arrange, never
|
|
// a sample file on disk (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank).
|
|
//
|
|
// THE PURE SEAMS: grid tiling / hit-test / selection math live in bank_grid; the
|
|
// mode-switch geometry in mode_switch; the named-banks TAB-STRIP layout, overflow/
|
|
// scroll, and hit-test in tab_strip. All three are unit-tested outside the DAW; only
|
|
// draw + input routing + the model calls live here.
|
|
//
|
|
// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural
|
|
// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector,
|
|
// so a BankIndex& / Bank* must NEVER be cached across one. Every handler below
|
|
// resolves fresh AFTER any mutation and passes bank IDS (not references) into the
|
|
// model ops.
|
|
|
|
#include "bank_panel.h"
|
|
|
|
#include <cstdint>
|
|
#include <cstdlib> // std::abs (drag threshold)
|
|
#include <filesystem>
|
|
#include <map>
|
|
#include <set>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
#include "action_bar.h" // pure TASK-GROUPED action-bar layout + hit-test (L2)
|
|
#include "action_buttons.h" // pure label format (formatButtonLabel) — reused by the L2 bar (M11)
|
|
#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path)
|
|
#include "drag_out.h" // pure gesture-boundary decision + path-list assembly (M11)
|
|
#include "drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11)
|
|
#include "app_version.h" // channelCommandId — compose the named-command lookup string (M11)
|
|
#include "bank_book.h"
|
|
#include "bank_grid.h"
|
|
#include "bank_model.h"
|
|
#include "capture_paths.h"
|
|
#include "component_geometry.h" // KitBox — the kit text()'s draw box (L1)
|
|
#include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1)
|
|
#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4)
|
|
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
|
|
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
|
|
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
|
|
#include "mode_switch.h"
|
|
#include "peaks.h"
|
|
#include "persist.h"
|
|
#include "prune_button.h" // footer prune-button layout + hit-test (pure, R3)
|
|
#include "render_settings.h" // captureActionTable — the table-driven button rows (M11)
|
|
#include "tab_strip.h"
|
|
#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
|
|
#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
|
|
#include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires
|
|
#include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2)
|
|
|
|
// SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP);
|
|
// on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32).
|
|
#ifdef _WIN32
|
|
#include <windows.h>
|
|
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
|
#else
|
|
#include <pthread.h>
|
|
#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 = 40; // taller — hosts the label + keybinding micro sub-row
|
|
constexpr int kBottomToolbarHeight = 40; // same shape (label + keybinding sub-row)
|
|
|
|
// --- 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)
|
|
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<std::string, CachedThumbnail> 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;
|
|
|
|
// --- 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<std::string> dragSampleIds;// snapshot of the selection at drag start
|
|
DropKind dropKind = DropKind::None; // live drop target under the pointer
|
|
std::string dropBankId; // destination bank id when dropKind==Tab
|
|
|
|
// --- 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<char> buf(4096, '\0');
|
|
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
|
std::string rpp(buf.data());
|
|
if (rpp.empty()) return {};
|
|
return normalizeSlashes(fs::path(rpp).parent_path().string());
|
|
}
|
|
|
|
// --- Book / bank accessors ----------------------------------------------------
|
|
|
|
BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; }
|
|
|
|
// The BankIndex a region currently displays. Pool region -> the pool; banks region ->
|
|
// the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved
|
|
// FRESH every call (never cached across a mutation).
|
|
const BankIndex* indexForRegion(Region r) {
|
|
BankBook* b = book();
|
|
if (!b) return nullptr;
|
|
if (r == Region::Pool) return &b->pool().index;
|
|
if (g_panel.shownBankId.empty()) return nullptr;
|
|
return b->index(g_panel.shownBankId);
|
|
}
|
|
|
|
// The bank id a region displays (pool id, or the shown tab's id; "" when none).
|
|
std::string bankIdForRegion(Region r) {
|
|
if (r == Region::Pool) return std::string(kPoolBankId);
|
|
return g_panel.shownBankId;
|
|
}
|
|
|
|
// The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh.
|
|
std::vector<const Bank*> namedBanks() {
|
|
std::vector<const Bank*> 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 (unchanged from M5) --------------------------------
|
|
|
|
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<std::int64_t>(lengthSec * srate);
|
|
if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; }
|
|
int frames = totalFrames > kMaxThumbnailFrames
|
|
? kMaxThumbnailFrames
|
|
: static_cast<int>(totalFrames);
|
|
|
|
std::vector<ReaSample> buf(static_cast<std::size_t>(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<std::size_t>(got) * nch;
|
|
std::vector<float> pcm(sampleCount);
|
|
for (std::size_t i = 0; i < sampleCount; ++i)
|
|
pcm[i] = static_cast<float>(buf[i]);
|
|
|
|
return computeEnvelope(pcm, static_cast<std::size_t>(nch),
|
|
static_cast<std::size_t>(got),
|
|
static_cast<std::size_t>(width));
|
|
}
|
|
|
|
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 (unchanged from M5) ----------------------------------
|
|
|
|
void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
|
bool selected, bool focused, bool hovered) {
|
|
// Cell surface through the kit: Active (accent) when selected, else hover-or-rest bg/cell.
|
|
// The grid is the centerpiece (bones preserved) — the surface picks up the L2 palette +
|
|
// micro-gradient while the waveform plot below stays the panel's own draw.
|
|
const KitBox cell{rect.x, rect.y, rect.width, rect.height};
|
|
const InteractionState state = selected ? InteractionState::Active
|
|
: (hovered ? InteractionState::Hover
|
|
: InteractionState::Rest);
|
|
fillSurface(bmp, cell, Role::BgCell, state);
|
|
|
|
// Border: accent when selected, else hairline. A focus ring is a distinct text/primary
|
|
// double-line (the kit's focus convention) so focus reads even on a selected cell.
|
|
const KitColor border = selected ? roleColor(Role::AccentPrimary) : 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 (peaks invariant: min<=max). The wave uses the accent role except on a
|
|
// selected cell (whose fill is already the accent) — there it draws in bg/base for contrast.
|
|
const LICE_pixel midCol = toLice(roleColor(Role::LineHairline));
|
|
const LICE_pixel waveCol =
|
|
toLice(selected ? roleColor(Role::BgBase) : roleColor(Role::AccentPrimary));
|
|
|
|
if (env.empty()) {
|
|
const int midY = rect.y + rect.height / 2;
|
|
LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false);
|
|
return;
|
|
}
|
|
|
|
const int channels = static_cast<int>(env.size());
|
|
const int bandH = rect.height / channels;
|
|
|
|
for (int ch = 0; ch < channels; ++ch) {
|
|
const ChannelEnvelope& bins = env[ch];
|
|
const int bandTop = rect.y + ch * bandH;
|
|
const int midY = bandTop + bandH / 2;
|
|
const double halfSpan = (bandH / 2) - 2;
|
|
|
|
LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false);
|
|
|
|
const int nbins = static_cast<int>(bins.size());
|
|
if (nbins <= 0) continue;
|
|
|
|
const int innerW = rect.width - 4;
|
|
for (int i = 0; i < nbins; ++i) {
|
|
const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
|
|
// min<=max always (peaks invariant). Draw a vertical line from the
|
|
// min sample to the max sample, clamped to the band.
|
|
int yMax = midY - static_cast<int>(compressAmplitudeForDisplay(bins[i].max) * halfSpan); // max -> up
|
|
int yMin = midY - static_cast<int>(compressAmplitudeForDisplay(bins[i].min) * halfSpan); // min -> down
|
|
if (yMax < bandTop) yMax = bandTop;
|
|
if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
|
|
LICE_Line(bmp, x, yMin, x, yMax, waveCol, 1.0f, 0, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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<int>(g_panel.session->view().modes().size());
|
|
}
|
|
|
|
// --- Top toolbar band (L4) ----------------------------------------------------
|
|
//
|
|
// 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.
|
|
ActionBarRect topToolbarRect(int w) {
|
|
ActionBarRect s;
|
|
s.x = 0;
|
|
s.y = 0;
|
|
s.width = w;
|
|
s.height = kTopToolbarHeight;
|
|
return s;
|
|
}
|
|
|
|
// --- 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<Mode>& modes = view.modes().all();
|
|
const int n = static_cast<int>(modes.size());
|
|
const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height};
|
|
const std::vector<SegmentRect> segs = computeSegmentRects(th, n);
|
|
const std::string& activeId = view.activeModeId();
|
|
for (int i = 0; i < static_cast<int>(segs.size()); ++i) {
|
|
const SegmentRect& s = segs[static_cast<std::size_t>(i)];
|
|
const Mode& mode = modes[static_cast<std::size_t>(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 "0.9.01" on stable and "0.9.01-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 label, 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.
|
|
struct ActionBarRow {
|
|
std::string suffix;
|
|
std::string shortLabel;
|
|
ActionCluster cluster = ActionCluster::Capture;
|
|
};
|
|
|
|
// The TOP toolbar inventory: Capture (item / track / batch items / batch razor / RT) then
|
|
// Placement (insert / insert-conform). Capture scopes come from captureActionTable()
|
|
// (render_settings, pure); the rest are the registered M11/M6 commands. RECONCILED against the
|
|
// actually-REGISTERED commands — "resample-and-mute" / "null-test verify" are not registered
|
|
// commands and drag-out is a mouse gesture, so none are placed. Built once per draw/click.
|
|
std::vector<ActionBarRow> topBarRows() {
|
|
std::vector<ActionBarRow> rows;
|
|
// Capture cluster — the primary gesture, leftmost.
|
|
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, ActionCluster::Capture});
|
|
}
|
|
rows.push_back({"CAPTURE_BATCH_ITEMS", "Batch Items", ActionCluster::Capture});
|
|
rows.push_back({"CAPTURE_BATCH_RAZOR", "Batch Razor", ActionCluster::Capture});
|
|
rows.push_back({"CAPTURE_TRACK_REALTIME", "Capture RT", ActionCluster::Capture});
|
|
// Placement cluster — the second act (still a distinct on-demand act; no auto-insert).
|
|
rows.push_back({"INSERT_SELECTED", "Insert", ActionCluster::Placement});
|
|
rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", ActionCluster::Placement});
|
|
return rows;
|
|
}
|
|
|
|
// The BOTTOM toolbar inventory: the Design-View action family, grouped Tagging then Switching
|
|
// (L4 §2). The suffixes are the ACTUAL registered command-id strings from actions.cpp
|
|
// (VIEW_TAG_DESIGN / VIEW_UNTAG / VIEW_ACTIVATE_ARRANGE / VIEW_ACTIVATE_DESIGN /
|
|
// VIEW_TOGGLE_MODE / VIEW_SHOW_BOTH) — grepped, not paraphrased. "Tag Design" tags the
|
|
// selection into the Design mode; "Untag" returns the selection to the Arrange default (the
|
|
// shared body behind both untag and tag->Arrange). Firing routes through the SAME command-id
|
|
// contract the keybindings use — L4 gives these registered actions a button home, unchanged.
|
|
std::vector<ActionBarRow> bottomBarRows() {
|
|
std::vector<ActionBarRow> rows;
|
|
// Tagging cluster.
|
|
rows.push_back({"VIEW_TAG_DESIGN", "Tag Design", ActionCluster::Tagging});
|
|
rows.push_back({"VIEW_UNTAG", "Untag", ActionCluster::Tagging});
|
|
// Switching cluster.
|
|
rows.push_back({"VIEW_ACTIVATE_ARRANGE", "Arrange", ActionCluster::Switching});
|
|
rows.push_back({"VIEW_ACTIVATE_DESIGN", "Design", ActionCluster::Switching});
|
|
rows.push_back({"VIEW_TOGGLE_MODE", "Toggle", ActionCluster::Switching});
|
|
rows.push_back({"VIEW_SHOW_BOTH", "Show Both", ActionCluster::Switching});
|
|
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, Placement; bottom: Tagging,
|
|
// Switching); Maintenance is retained for completeness though no current toolbar uses it.
|
|
std::vector<ClusterSpec> actionBarClusters(const std::vector<ActionBarRow>& 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::Placement, nPlace},
|
|
{ActionCluster::Maintenance, nMaint},
|
|
{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).
|
|
const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/16,
|
|
/*sidePad=*/8, /*verticalInset=*/3, /*bindingHeight=*/11,
|
|
/*minSplitHeight=*/30};
|
|
|
|
// 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 NAME on the label row and the key
|
|
// binding (or "unbound") on the Micro sub-row. 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.
|
|
void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar,
|
|
const std::vector<ActionBarRow>& 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<ClusterSpec> clusters = actionBarClusters(rows);
|
|
const std::vector<ActionBarSlot> slots = computeBarSlots(bar, clusters, kBarSpec);
|
|
|
|
for (const ActionBarSlot& s : slots) {
|
|
if (s.index < 0 || s.index >= static_cast<int>(rows.size())) continue;
|
|
const ActionBarRow& row = rows[static_cast<std::size_t>(s.index)];
|
|
const int cmd = resolveBarCommandId(row);
|
|
|
|
// State: Disabled when the action is not registered on this channel; else Hover when
|
|
// hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.)
|
|
InteractionState state = InteractionState::Rest;
|
|
if (cmd == 0) 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 below so the binding sub-row can use the
|
|
// Micro font, so 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);
|
|
|
|
if (!s.bindingEmpty()) {
|
|
// The keybinding help sub-label, dim + Micro. formatButtonLabel's blank/unbound
|
|
// collapse is reused so an unbound action reads "(unbound)" cleanly; here we want
|
|
// just the binding token (name is already on the label row), so format the binding
|
|
// alone and strip the leading name-less case.
|
|
const std::string binding = barBindingText(cmd);
|
|
const std::string sub = formatButtonLabel("", binding); // "" + " (unbound)" / " <bind>"
|
|
std::size_t start = sub.find_first_not_of(' ');
|
|
const std::string shown = (start == std::string::npos) ? sub : sub.substr(start);
|
|
const KitBox bindBox{s.bindX, s.bindY, s.bindW, s.bindH};
|
|
kitText(bmp, bindBox, shown.c_str(), Font::Micro, Role::TextDim, 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<ActionBarRow>& 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<ActionBarRow>& 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 int cmd = resolveBarCommandId(rows[static_cast<std::size_t>(hit)]);
|
|
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
|
|
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;
|
|
}
|
|
|
|
// The cell rects for a region's grid, translated into the region's grid viewport.
|
|
// Both paint and hit-testing call this. Empty when the index is null/empty.
|
|
std::vector<CellRect> regionCellRects(const RECT& region, bool isBanks,
|
|
const BankIndex* index) {
|
|
if (!index || index->empty()) return {};
|
|
const RECT grid = regionGridRect(region, isBanks);
|
|
const int w = grid.right - grid.left;
|
|
if (w <= 0) return {};
|
|
std::vector<CellRect> rects =
|
|
computeCellRects(static_cast<int>(index->size()), w, kGrid);
|
|
for (CellRect& r : rects) { r.x += grid.left; r.y += grid.top; }
|
|
return rects;
|
|
}
|
|
|
|
// --- Drawing: a grid region ---------------------------------------------------
|
|
|
|
// Draws one region's grid of thumbnails (or an empty-state line) clipped to its
|
|
// viewport. `selectionOwner` is true when this region holds the live selection, so
|
|
// its cells show selection/focus chrome; the other region draws plain.
|
|
void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks,
|
|
const BankIndex* index, const std::string& emptyMsg,
|
|
bool selectionOwner, const std::string& projectDir) {
|
|
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;
|
|
}
|
|
|
|
const std::vector<Sample>& samples = index->all();
|
|
const std::vector<CellRect> rects = regionCellRects(region, isBanks, index);
|
|
const int binWidth = kGrid.cellWidth - 4;
|
|
for (std::size_t i = 0; i < rects.size(); ++i) {
|
|
const CellRect& rect = rects[i];
|
|
if (rect.y >= grid.bottom) continue; // below the viewport: skip (no scroll)
|
|
const int idx = static_cast<int>(i);
|
|
const bool selected = selectionOwner && g_panel.selection.contains(idx);
|
|
const bool focused = selectionOwner && g_panel.selection.focus == idx;
|
|
const Envelope& env = thumbnailFor(samples[i], 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);
|
|
}
|
|
}
|
|
|
|
// 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<const Bank*> tabs = namedBanks();
|
|
const int n = static_cast<int>(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<TabRect> rects =
|
|
computeTabRects(strip, n, kTabSpec, g_panel.tabScroll);
|
|
for (const TabRect& tr : rects) {
|
|
const Bank* bk = tabs[static_cast<std::size_t>(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);
|
|
// Drop-target highlight for the pool region during a drag.
|
|
if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
// 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) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// L4 three-zone chrome: TOP toolbar (capture + placement), BOTTOM toolbar (Design-View
|
|
// verbs), then the footer (mode toggle + count + Tail button + Prune). Drawn last so they
|
|
// sit over the split body's edges.
|
|
drawToolbar(&bmp, topToolbarRect(w), topBarRows(), HoverKind::TopBarButton,
|
|
/*topDivider=*/false);
|
|
drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton,
|
|
/*topDivider=*/true);
|
|
drawFooter(&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<const Bank*> named = namedBanks();
|
|
g_panel.shownBankId = named.empty() ? std::string() : named.front()->id;
|
|
}
|
|
|
|
bool refreshFingerprint() {
|
|
if (!book()) return false;
|
|
std::string fp = bookFingerprint();
|
|
if (fp == g_panel.bankFingerprint) return false;
|
|
g_panel.bankFingerprint = std::move(fp);
|
|
++g_panel.generation;
|
|
g_panel.cache.clear();
|
|
// The selection indexes into the OLD order; a change can invalidate those, so
|
|
// clear it and stop any audition.
|
|
if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) {
|
|
g_panel.selection = Selection{};
|
|
stopAudition();
|
|
}
|
|
reconcileShownBank();
|
|
const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
|
|
g_panel.selItemCount = idx ? static_cast<int>(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<int>(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.
|
|
//
|
|
// 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<std::string>& allGuids,
|
|
std::map<std::string, bool>& itemOnManualLane) {
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<std::string> live;
|
|
std::map<std::string, bool> itemOnManualLane;
|
|
enumerateLiveGuids(proj, live, itemOnManualLane);
|
|
|
|
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
|
|
if (added.empty()) return false; // first poll after open, or nothing new this tick
|
|
|
|
// 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<std::string> newTracks;
|
|
std::vector<NewItem> newItems;
|
|
for (const std::string& g : added) {
|
|
auto it = itemOnManualLane.find(g);
|
|
if (it == itemOnManualLane.end()) {
|
|
newTracks.push_back(g); // a track GUID
|
|
} else {
|
|
newItems.push_back(NewItem{g, it->second}); // an item; carries its exemption
|
|
}
|
|
}
|
|
|
|
ViewModeModel& model = g_panel.session->view();
|
|
const std::vector<AutoTag> 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 sample `idx` of the FOCUSED region's displayed bank.
|
|
void startAudition(int idx) {
|
|
stopAudition();
|
|
|
|
const BankIndex* index = indexForRegion(g_panel.focusedRegion);
|
|
if (!index) return;
|
|
const std::vector<Sample>& samples = index->all();
|
|
if (idx < 0 || idx >= static_cast<int>(samples.size())) return;
|
|
|
|
const std::string projectDir = currentProjectDir();
|
|
const std::string abs = resolveBankFile(projectDir, samples[idx].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; }
|
|
|
|
void invalidatePanel() {
|
|
if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE);
|
|
}
|
|
|
|
// The item count of the focused region's bank (0 when none).
|
|
int focusedItemCount() {
|
|
const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
|
|
return idx ? static_cast<int>(idx->size()) : 0;
|
|
}
|
|
|
|
// Which region (if any) contains client point (x, y); returns false via `out` set to
|
|
// Pool by default when the point is in neither region body.
|
|
bool regionAt(int x, int y, Region& out) {
|
|
RECT cr{};
|
|
GetClientRect(g_panel.hwnd, &cr);
|
|
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
|
|
if (poolShown()) {
|
|
const RECT r = poolRegionRect(w, h);
|
|
if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) {
|
|
out = Region::Pool; return true;
|
|
}
|
|
}
|
|
if (banksShown()) {
|
|
const RECT r = banksRegionRect(w, h);
|
|
if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) {
|
|
out = Region::Banks; return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- Bank management ops (id-keyed; drive the B1 model + persist) --------------
|
|
//
|
|
// Each op mutates g_session.book() then persists via persistBankOp(). After a
|
|
// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankIndex& is invalid — we
|
|
// resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an
|
|
// unsaved project the empty-close discard in persistBankOp ensures no stale state
|
|
// survives (matches the capture/B3 quiet-persist idiom).
|
|
|
|
// REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3).
|
|
bool promptText(const char* title, const char* caption, const std::string& initial,
|
|
std::string& out) {
|
|
std::vector<char> 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<int>(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;
|
|
persistBankOp("ReaSampler: delete bank");
|
|
// 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");
|
|
invalidatePanel();
|
|
}
|
|
|
|
void doActivateBank(const std::string& bankId) {
|
|
if (!book()) return;
|
|
if (!book()->setActiveBank(bankId)) return; // rejects an unknown id
|
|
persistBankOp("ReaSampler: activate bank");
|
|
invalidatePanel();
|
|
}
|
|
|
|
// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass
|
|
// ids straight to the model op (no BankIndex& cached across the loop's mutations).
|
|
//
|
|
// NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected):
|
|
// * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally
|
|
// before the dest add collapses on hash), so the index DID mutate — counts.
|
|
// * COPY collapse: the source is left intact AND the dest already held the hash,
|
|
// so NOTHING changed — a true index no-op. Must NOT open an undo point.
|
|
// Hence: copy counts only real gains (Copied); move counts gains OR collapses.
|
|
void transferSamples(const std::vector<std::string>& 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);
|
|
// The selection indexed into the source; after a move those indices are stale, so
|
|
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
|
|
g_panel.selection = Selection{};
|
|
invalidatePanel();
|
|
}
|
|
|
|
// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to
|
|
// the file: a last-reference remove leaves the file on disk, orphaned until Phase R
|
|
// prune — remove NEVER deletes bytes (the manifest is untouched). Removes are silent
|
|
// (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) —
|
|
// one Ctrl-Z restores the index entry. Ids passed by value — no BankIndex& cached
|
|
// across the loop's mutations.
|
|
void removeSamples(const std::vector<std::string>& 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)");
|
|
// 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<std::string> focusedSelectionIds() {
|
|
std::vector<std::string> ids;
|
|
const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
|
|
if (!idx) return ids;
|
|
const std::vector<Sample>& samples = idx->all();
|
|
const int count = static_cast<int>(samples.size());
|
|
for (int i : g_panel.selection.indices)
|
|
if (i >= 0 && i < count) ids.push_back(samples[static_cast<std::size_t>(i)].id);
|
|
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<std::string> resolveDragPathsForOs() {
|
|
std::vector<ResolvedSample> resolved;
|
|
BankBook* b = book();
|
|
if (!b) return {};
|
|
const BankIndex* idx = b->index(g_panel.dragSourceBankId);
|
|
if (!idx) return {};
|
|
|
|
const std::string projectDir = currentProjectDir();
|
|
resolved.reserve(g_panel.dragSampleIds.size());
|
|
for (const std::string& sid : g_panel.dragSampleIds) {
|
|
const Sample* s = idx->query(sid);
|
|
if (!s) continue; // stale id — the pure layer would skip it anyway; nothing to resolve
|
|
ResolvedSample rs;
|
|
rs.absolutePath = resolveBankFile(projectDir, s->relativePath);
|
|
rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath));
|
|
resolved.push_back(std::move(rs));
|
|
}
|
|
return assemblePathList(resolved).paths;
|
|
}
|
|
|
|
// --- Popup menus --------------------------------------------------------------
|
|
//
|
|
// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu
|
|
// -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of
|
|
// (label -> small int command), track it at screen coords, and switch on the return.
|
|
// Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD
|
|
// hands the chosen id straight back, so no hookcommand routing is involved.
|
|
|
|
// Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both
|
|
// accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative
|
|
// position appending. Win32 and SWELL both treat pos < 0 as an append.
|
|
void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) {
|
|
UINT flags = MF_BYPOSITION | MF_STRING;
|
|
if (grayed) flags |= MF_GRAYED;
|
|
InsertMenu(menu, -1, flags, id, text);
|
|
}
|
|
void menuSeparator(HMENU menu) {
|
|
InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr);
|
|
}
|
|
|
|
// Menu command ids (local to a popup).
|
|
enum : unsigned int {
|
|
kMenuNone = 0,
|
|
kMenuActivate = 100,
|
|
kMenuRename,
|
|
kMenuDelete,
|
|
kMenuEvacuate,
|
|
kMenuCreate,
|
|
kMenuRemove, // remove selected sample(s) from the source bank (B5)
|
|
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
|
|
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
|
};
|
|
|
|
// Shows the right-click context menu for a named-bank TAB: activate / rename / delete
|
|
// / evacuate that bank, plus a create entry. Drives the id-keyed ops.
|
|
void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
|
if (!book()) return;
|
|
const Bank* bk = book()->bank(bankId);
|
|
if (!bk || bk->isPool()) return;
|
|
const bool isActive = book()->activeBankId() == bankId;
|
|
const bool nonEmpty = !bk->index.empty();
|
|
|
|
HMENU menu = CreatePopupMenu();
|
|
menuAppend(menu, kMenuActivate,
|
|
isActive ? "Active (capture target)" : "Activate (make capture target)",
|
|
/*grayed=*/isActive);
|
|
menuSeparator(menu);
|
|
menuAppend(menu, kMenuRename, "Rename...");
|
|
menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty);
|
|
menuAppend(menu, kMenuDelete, "Delete...");
|
|
menuSeparator(menu);
|
|
menuAppend(menu, kMenuCreate, "New bank...");
|
|
|
|
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
|
|
g_panel.hwnd, nullptr);
|
|
DestroyMenu(menu);
|
|
|
|
switch (cmd) {
|
|
case kMenuActivate: doActivateBank(bankId); break;
|
|
case kMenuRename: doRenameBank(bankId); break;
|
|
case kMenuEvacuate: doEvacuateBank(bankId); break;
|
|
case kMenuDelete: doDeleteBank(bankId); break;
|
|
case kMenuCreate: doCreateBank(); break;
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
// 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<std::string> 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<Dest> 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<unsigned int>(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<unsigned int>(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<int>(kMenuRemove)) {
|
|
removeSamples(sel, srcId);
|
|
} else if (cmd >= static_cast<int>(kMenuMoveBase) &&
|
|
cmd < static_cast<int>(kMenuMoveBase + dests.size())) {
|
|
transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false);
|
|
} else if (cmd >= static_cast<int>(kMenuCopyBase) &&
|
|
cmd < static_cast<int>(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<const Bank*> tabs = namedBanks();
|
|
const int n = static_cast<int>(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<std::size_t>(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 (capture + placement) takes precedence — 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 (unchanged actions).
|
|
if (handleToolbarClick(x, y, topToolbarRect(w), topBarRows())) 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<Mode>& modes = g_panel.session->view().modes().all();
|
|
if (seg < static_cast<int>(modes.size())) {
|
|
applyMode(g_panel.session->view(),
|
|
modes[static_cast<std::size_t>(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);
|
|
const BankIndex* index = indexForRegion(reg);
|
|
const std::vector<CellRect> rects = regionCellRects(region, isBanks, index);
|
|
const int hit = hitTestCell(x, y, rects);
|
|
const int count = index ? static_cast<int>(index->size()) : 0;
|
|
|
|
// 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;
|
|
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<std::string> 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<int>(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<const Bank*> tabs = namedBanks();
|
|
const TabHit hit = hitTestTabStrip(x, y, strip,
|
|
static_cast<int>(tabs.size()), kTabSpec,
|
|
g_panel.tabScroll);
|
|
if (hit.kind == TabHitKind::Tab) {
|
|
g_panel.dropKind = DropKind::Tab;
|
|
g_panel.dropBankId = tabs[static_cast<std::size_t>(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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 buttons (matching the click order — first zone top-to-bottom).
|
|
{
|
|
const int hit = toolbarHit(x, y, topToolbarRect(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<const Bank*> tabs = namedBanks();
|
|
const TabHit hit = hitTestTabStrip(x, y, strip, static_cast<int>(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).
|
|
void updateHover(int x, int y) {
|
|
const Hover next = resolveHover(x, y);
|
|
if (next != g_panel.hovered) {
|
|
g_panel.hovered = next;
|
|
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();
|
|
g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now
|
|
SetCapture(g_panel.hwnd);
|
|
}
|
|
}
|
|
if (g_panel.dragging) {
|
|
// M11 gesture boundary (invariant #4): while a drag with samples is under way, the
|
|
// moment the pointer LEAVES the panel client area the gesture becomes OS-bound —
|
|
// hand the payload to the native OS drag. Inside the client area it stays the
|
|
// existing internal bank-to-bank drag, byte-identical. The boundary decision is the
|
|
// pure drag_out::decideGesture (drag state + pointer + client rect).
|
|
RECT cr{};
|
|
GetClientRect(g_panel.hwnd, &cr);
|
|
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
|
|
const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
|
if (decideGesture(x, y, client, st) == DragGesture::OsDrag) {
|
|
// Resolve the payload to existing on-disk paths BEFORE tearing down internal
|
|
// drag state (the resolver reads dragSourceBankId / dragSampleIds).
|
|
const std::vector<std::string> 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();
|
|
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;
|
|
}
|
|
updateDropTarget(x, y);
|
|
invalidatePanel();
|
|
}
|
|
}
|
|
|
|
// Commits (or abandons) a drag on button-up. A drop onto a DIFFERENT bank moves the
|
|
// dragged samples there; a drop onto the source bank / dead space is a no-op. Ctrl
|
|
// held at drop = copy (the deliberate secondary), else move.
|
|
void onLBtnUp(int x, int y) {
|
|
if (g_panel.dragging) {
|
|
updateDropTarget(x, y);
|
|
std::string destId;
|
|
if (g_panel.dropKind == DropKind::PoolRegion) destId = std::string(kPoolBankId);
|
|
else if (g_panel.dropKind == DropKind::Tab ||
|
|
g_panel.dropKind == DropKind::BanksRegion)
|
|
destId = g_panel.dropBankId;
|
|
|
|
if (!destId.empty() && destId != g_panel.dragSourceBankId &&
|
|
!g_panel.dragSampleIds.empty()) {
|
|
transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId,
|
|
/*copy=*/ctrlDown());
|
|
}
|
|
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).
|
|
const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
|
|
const int count = idx ? static_cast<int>(idx->size()) : 0;
|
|
const int focus = g_panel.selection.focus;
|
|
if (focus >= 0)
|
|
g_panel.selection = applyClick(g_panel.selection, focus, false, false, count);
|
|
}
|
|
g_panel.dragArmed = false;
|
|
g_panel.dragging = false;
|
|
g_panel.dropKind = DropKind::None;
|
|
g_panel.dropBankId.clear();
|
|
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<const Bank*> tabs = namedBanks();
|
|
const TabHit hit = hitTestTabStrip(x, y, strip,
|
|
static_cast<int>(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<std::size_t>(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 ----------------------------------------------------
|
|
|
|
WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
|
switch (msg) {
|
|
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 before a drag began (e.g. pointer left window pre-threshold
|
|
// and button released outside) — disarm so the state doesn't stay stale.
|
|
if (g_panel.dragArmed && !g_panel.dragging) {
|
|
g_panel.dragArmed = false;
|
|
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<short>(HIWORD(wParam));
|
|
return handleWheel(pt.x, pt.y, delta) ? 1 : 0;
|
|
}
|
|
case WM_DESTROY:
|
|
if (GetCapture() == hwnd) ReleaseCapture();
|
|
stopAudition();
|
|
g_panel.selection = Selection{};
|
|
g_panel.dragArmed = g_panel.dragging = false;
|
|
g_panel.hovered = Hover{};
|
|
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;
|
|
|
|
registerAccel();
|
|
|
|
reconcileShownBank();
|
|
refreshFingerprint();
|
|
}
|
|
|
|
void closePanel() {
|
|
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
|
|
stopAudition();
|
|
g_panel.selection = Selection{};
|
|
g_panel.dragArmed = g_panel.dragging = false;
|
|
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;
|
|
}
|
|
|
|
void bankPanelToggle() {
|
|
if (g_panel.open)
|
|
closePanel();
|
|
else
|
|
openPanel();
|
|
}
|
|
|
|
bool bankPanelIsOpen() {
|
|
return g_panel.open;
|
|
}
|
|
|
|
std::vector<std::string> 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;
|
|
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 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
|