diff --git a/CMakeLists.txt b/CMakeLists.txt index 2928690..8e2cbbb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -301,6 +301,20 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge add_library(provenance STATIC src/provenance.cpp) target_include_directories(provenance PUBLIC src) +# --------------------------------------------------------------------------- +# 2k) Pure action_buttons library — NO REAPER, NO SWELL. The Milestone 11 +# action-trigger button strip: strip rect + N buttons at a minimum width -> +# per-button rects (equal tiling; overflow HIDES excess on a narrow panel +# rather than clipping), point -> button hit-test, and the button label format +# (action name + SDK binding string -> label, with the unbound/blank case +# degraded to an explicit marker and an over-long binding truncated). Split out +# so the layout + label math is unit-tested outside the DAW; the bank_panel +# draw + NamedCommandLookup/Main_OnCommand dispatch + kbd_getTextFromCmd query +# are DAW-verified. Mirror of mode_switch / tab_strip. +# --------------------------------------------------------------------------- +add_library(action_buttons STATIC src/action_buttons.cpp) +target_include_directories(action_buttons PUBLIC src) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -395,6 +409,10 @@ add_executable(provenance_tests tests/test_provenance.cpp) target_link_libraries(provenance_tests PRIVATE provenance bank_model) add_test(NAME provenance_tests COMMAND provenance_tests) +add_executable(action_buttons_tests tests/test_action_buttons.cpp) +target_link_libraries(action_buttons_tests PRIVATE action_buttons) +add_test(NAME action_buttons_tests COMMAND action_buttons_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -433,7 +451,7 @@ add_library(reaper_reasampler MODULE src/bank_book.cpp src/owned_manifest.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/src/action_buttons.cpp b/src/action_buttons.cpp new file mode 100644 index 0000000..fd8ca3d --- /dev/null +++ b/src/action_buttons.cpp @@ -0,0 +1,107 @@ +// action_buttons — pure implementation. See action_buttons.h. NO REAPER / SWELL / vendor. + +#include "action_buttons.h" + +#include + +namespace reasampler { + +namespace { + +// The left edge of button i when `count` buttons share a strip of the given x-origin and +// width. Boundary i is x + (i * width) / count, so button i spans [edge(i), edge(i+1)). +// Every boundary derives from the same formula, so consecutive buttons share an exact +// edge (no gap, no overlap) and edge(count) == x + width precisely. count assumed >= 1. +int buttonEdge(int x, int width, int i, int count) { + return x + (i * width) / count; +} + +// True for an ASCII space or tab (the whitespace the SDK binding string might carry). +bool isBlankChar(char c) { return c == ' ' || c == '\t'; } + +// The trimmed [first, last) view of `s` with leading/trailing blanks removed. Returns +// an empty range when `s` is all blanks. +std::string trimBlanks(const std::string& s) { + std::size_t b = 0; + std::size_t e = s.size(); + while (b < e && isBlankChar(s[b])) ++b; + while (e > b && isBlankChar(s[e - 1])) --e; + return s.substr(b, e - b); +} + +} // namespace + +ButtonFit computeButtonFit(const ButtonStripRect& strip, int buttonCount, + int minButtonWidth) { + ButtonFit fit; + if (buttonCount <= 0 || strip.width <= 0 || minButtonWidth <= 0) return fit; + + int fits = strip.width / minButtonWidth; // how many min-width buttons the strip holds + if (fits > buttonCount) fits = buttonCount; + if (fits < 0) fits = 0; + + fit.visibleCount = fits; + fit.hiddenCount = buttonCount - fits; + return fit; +} + +std::vector computeButtonRects(const ButtonStripRect& strip, int buttonCount, + int minButtonWidth) { + std::vector rects; + const ButtonFit fit = computeButtonFit(strip, buttonCount, minButtonWidth); + const int n = fit.visibleCount; + if (n <= 0) return rects; + + rects.reserve(static_cast(n)); + for (int i = 0; i < n; ++i) { + const int left = buttonEdge(strip.x, strip.width, i, n); + const int right = buttonEdge(strip.x, strip.width, i + 1, n); + ActionButtonRect r; + r.index = i; + r.x = left; + r.y = strip.y; + r.width = right - left; // absorbs rounding; visible buttons abut and fill the strip + r.height = strip.height; + rects.push_back(r); + } + return rects; +} + +int hitTestButton(int px, int py, const ButtonStripRect& strip, int buttonCount, + int minButtonWidth) { + if (strip.height <= 0) return -1; + // Reject anything outside the strip band first (half-open bounds match the rects). + if (px < strip.x || px >= strip.x + strip.width || + py < strip.y || py >= strip.y + strip.height) + return -1; + + const ButtonFit fit = computeButtonFit(strip, buttonCount, minButtonWidth); + const int n = fit.visibleCount; + if (n <= 0) return -1; + + // Inside the band: find the visible button whose [edge(i), edge(i+1)) contains px. + // A point past the last visible button's right edge (narrow-panel overflow dead-zone) + // falls through to -1. + for (int i = 0; i < n; ++i) { + const int left = buttonEdge(strip.x, strip.width, i, n); + const int right = buttonEdge(strip.x, strip.width, i + 1, n); + if (px >= left && px < right) return i; + } + return -1; +} + +std::string formatButtonLabel(const std::string& name, const std::string& rawBinding) { + const std::string binding = trimBlanks(rawBinding); + if (binding.empty()) { + return name + " (" + kUnboundMarker + ")"; + } + + std::string shown = binding; + if (static_cast(shown.size()) > kMaxBindingChars) { + // Keep the leading portion and mark the truncation with a single-width "~". + shown = shown.substr(0, static_cast(kMaxBindingChars - 1)) + "~"; + } + return name + " " + shown; +} + +} // namespace reasampler diff --git a/src/action_buttons.h b/src/action_buttons.h new file mode 100644 index 0000000..696bf1c --- /dev/null +++ b/src/action_buttons.h @@ -0,0 +1,125 @@ +#pragma once +// action_buttons — the REAPER-free layout + label-format math behind the bank_panel's +// action-trigger button strip (Milestone 11). A row of LICE-drawn buttons in the docked +// panel fires the capture / insert / provenance actions THROUGH the existing command-id +// contract (the shell resolves each button's command id at runtime via NamedCommandLookup +// and dispatches with Main_OnCommand — this module never touches REAPER), and each button +// surfaces the action's current key binding as a reminder label. +// +// What is NOT DAW-bound lives here so it is unit-tested outside the DAW (CLAUDE.md +// §load-bearing split), and it is two mirror-of-mode_switch concerns in one pure module: +// +// 1. Button-strip layout + hit-test. Unlike the mode switch (which tiles N EQUAL +// segments at any width), the button strip must degrade gracefully on a narrow +// panel: buttons never shrink below a minimum readable width — instead only as many +// as fit are laid out (equally sharing the strip) and the rest are reported hidden. +// That is the "overflow handling for narrow panels — no clipped garbage" requirement. +// 2. Label-text formatting: (action name + the SDK's binding string) -> the label drawn +// on a button, with the unbound / empty / blank cases degraded to an explicit marker +// and an over-long binding truncated so it never blows the button's text budget. +// +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library only. +// Builds and unit-tests without REAPER. Mirror of mode_switch / tab_strip. + +#include +#include + +namespace reasampler { + +// The strip the buttons are drawn into, top-left origin (SWELL/LICE convention). +// (x, y) is the top-left corner; width/height are the strip extents. The panel reserves +// this as a fixed-height band (its own judgment where — above the tail footer). +struct ButtonStripRect { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + bool operator==(const ButtonStripRect& o) const { + return x == o.x && y == o.y && width == o.width && height == o.height; + } +}; + +// One button's pixel rectangle within the strip, top-left origin, plus the index of the +// action it drives in the caller's list (button order). The shell draws the button here, +// labels it, and — on a hit at this index — resolves that action's command id and fires. +// Only VISIBLE buttons get a rect (a button that does not fit the strip is omitted, never +// returned with a clipped/zero width), so every returned rect is fully drawable. +struct ActionButtonRect { + int index = 0; + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + bool operator==(const ActionButtonRect& o) const { + return index == o.index && x == o.x && y == o.y && + width == o.width && height == o.height; + } +}; + +// How many of `buttonCount` buttons fit `strip` at a minimum button width of +// `minButtonWidth`. This is the overflow decision, split from the rect tiling so the +// shell can size an "overflow" affordance / count without re-deriving it. Clamps to +// [0, buttonCount]; a non-positive strip width or minButtonWidth yields 0. When all fit, +// visibleCount == buttonCount and hiddenCount == 0. +struct ButtonFit { + int visibleCount = 0; // buttons that fit (and will be laid out) + int hiddenCount = 0; // buttonCount - visibleCount (the overflow) +}; + +ButtonFit computeButtonFit(const ButtonStripRect& strip, int buttonCount, + int minButtonWidth); + +// Tiles the VISIBLE buttons (per computeButtonFit) left-to-right across `strip`, sharing +// its full width EQUALLY (exact tiling, rounding absorbed at boundaries so buttons abut +// with no gap/overlap and the last visible button reaches strip.x + strip.width — the +// same boundary discipline as mode_switch). Buttons never render narrower than +// minButtonWidth: when not all fit, the visible ones each get width >= minButtonWidth by +// construction (fewer buttons over the same strip). Returns exactly visibleCount rects in +// button-index order (indices 0..visibleCount-1). buttonCount <= 0, non-positive strip +// width, or non-positive minButtonWidth -> empty. +std::vector computeButtonRects(const ButtonStripRect& strip, int buttonCount, + int minButtonWidth); + +// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out for +// `buttonCount` buttons at `minButtonWidth`. Returns the index of the button containing +// the point, or -1 for a miss: outside the strip band entirely, or in the strip band but +// past the last visible button (the overflow dead-zone on a narrow panel — a harmless +// no-op the shell ignores). Half-open bounds [x, x+width) x [y, y+height) match +// computeButtonRects so no pixel is double-claimed and the hit maps to the button drawn +// there. +int hitTestButton(int px, int py, const ButtonStripRect& strip, int buttonCount, + int minButtonWidth); + +// --- Label formatting --------------------------------------------------------- +// +// The SDK's kbd_getTextFromCmd returns the binding text for a command in the main +// section (e.g. "Ctrl+Shift+C"), or an empty / whitespace-only / null string when the +// action is unbound. The shell reads that raw string; this function turns +// (short action name + raw binding) into the label drawn on the button, with every +// degenerate binding collapsed to ONE explicit unbound marker so an unbound button reads +// clearly rather than showing a stray separator or a blank tail. + +// The marker appended when an action has no key binding. An em-dash-free ASCII marker so +// it renders in any SWELL font; the label reads e.g. "Capture Item (unbound)". +inline constexpr const char* kUnboundMarker = "unbound"; + +// Max characters of the binding string kept in the label. A pathological binding (a long +// multi-chord custom binding) is truncated with a trailing ellipsis so it never overruns +// the button's text budget; DrawText's own DT_END_ELLIPSIS is a per-pixel backstop, but +// bounding the string here keeps the label deterministic and testable. +inline constexpr int kMaxBindingChars = 24; + +// Formats a button label from the action's short name and the raw SDK binding string. +// * Bound: " " (two spaces separate name and binding). +// * Unbound: " (unbound)" when `rawBinding` is empty, or contains only +// whitespace (spaces/tabs), or is otherwise blank — all collapse to the +// single marker. +// * A binding longer than kMaxBindingChars is truncated to kMaxBindingChars-1 chars +// plus a "~" ellipsis (kept ASCII, single-width) so the whole label stays bounded. +// Leading/trailing whitespace on a non-blank binding is trimmed before formatting. +// `name` is passed through verbatim (already short — the shell supplies a terse label). +std::string formatButtonLabel(const std::string& name, const std::string& rawBinding); + +} // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 30e6d47..756470d 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -46,7 +46,9 @@ #include #include +#include "action_buttons.h" // pure button-strip layout + label format (M11) #include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) +#include "app_version.h" // channelCommandId — compose the named-command lookup string (M11) #include "bank_book.h" #include "bank_grid.h" #include "bank_model.h" @@ -58,6 +60,7 @@ #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) @@ -106,6 +109,13 @@ #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. @@ -161,6 +171,20 @@ const LICE_pixel kColPruneBtnBg = LICE_RGBA(62, 46, 42, 255); const LICE_pixel kColPruneBtnBorder = LICE_RGBA(96, 72, 66, 255); const COLORREF kRgbPruneBtnText = RGB(210, 188, 180); +// --- Action-trigger button strip (M11) ---------------------------------------- +// A fixed-height band of LICE-drawn buttons directly ABOVE the tail footer (below the +// split body). Each button fires a registered action via the command-id contract and +// shows its current key binding. The strip's layout / hit-test / label format is the +// pure action_buttons module; only draw + dispatch + the SDK binding query live here. +constexpr int kButtonStripHeight = 28; +constexpr int kButtonMinWidth = 96; // buttons never draw narrower (overflow hides excess) + +const LICE_pixel kColBtnStripBg = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColBtnStripBorder = LICE_RGBA(70, 70, 76, 255); +const LICE_pixel kColActionBtnBg = LICE_RGBA(48, 48, 52, 255); +const LICE_pixel kColActionBtnBorder = LICE_RGBA(90, 90, 96, 255); +const COLORREF kRgbActionBtnText = RGB(210, 215, 220); + // --- Vertical split + region headers + tab strip (Phase B4) ------------------- // // The client area, top to bottom: mode-switch header (kHeaderHeight) | split body | @@ -622,6 +646,148 @@ void markTailDirty() { if (proj) MarkProjectDirty(proj); } +// === Action-trigger button strip (M11) — one bounded region =================== +// +// A row of clickable buttons that FIRE the registered capture / insert / provenance +// actions THROUGH the existing command-id contract, never re-implementing capture. Each +// button resolves its command id at RUNTIME from the composed named-command string +// (NamedCommandLookup on "_" + channelCommandId(suffix) — the same id minted at +// registration in main.cpp), so it is channel-correct on stable and beta automatically +// and adds NO second registration. Labels surface the current key binding via +// kbd_getTextFromCmd (main section). The layout / hit-test / label-format math is the +// pure action_buttons module; only the draw + SDK query + dispatch live here. + +// One button row: the channel-AGNOSTIC command-id suffix (composed with the channel +// prefix at runtime — never a hardcoded numeric id) and the terse on-button label. +struct ActionButtonRow { + std::string suffix; // e.g. "CAPTURE_ITEM" — composed via channelCommandId at fire time + std::string shortLabel; // e.g. "Capture Item" — the button's action-name text +}; + +// The button row set, TABLE-DRIVEN so future actions appear with minimal wiring: the two +// capture scopes come straight from captureActionTable() (render_settings, pure), then the +// known singleton actions main.cpp registers (realtime capture/cancel, insert native/ +// conform, re-capture from source). Order is capture-first (the primary gesture), then +// realtime, then placement, then re-capture. Built once per draw/click — cheap (a handful +// of small strings) and always in step with the registered families. +std::vector actionButtonRows() { + std::vector rows; + for (const CaptureActionDef& def : captureActionTable()) { + // descriptionPhrase is the long Actions-list phrase ("capture selected track(s)"); + // the button wants a terse label, so map the two known scopes by suffix. + 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}); + } + // Singleton actions (FOREVER-STABLE suffixes, mirrored from main.cpp's registration). + rows.push_back({"CAPTURE_TRACK_REALTIME", "Capture RT"}); + rows.push_back({"CANCEL_REALTIME_CAPTURE", "Cancel RT"}); + rows.push_back({"INSERT_SELECTED", "Insert"}); + rows.push_back({"INSERT_SELECTED_CONFORM", "Insert (conform)"}); + rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture"}); + return rows; +} + +// The button strip band: a fixed-height strip directly above the tail footer. Empty +// (degenerate) when the client is too short to host it above the footer. +ButtonStripRect actionButtonStrip(int w, int h) { + ButtonStripRect 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 = kButtonStripHeight; + s.y = footerTop - kButtonStripHeight; + // Keep the strip below the mode-switch header; if the client is too short, collapse it. + if (s.y < kHeaderHeight) { s.y = footerTop; s.height = 0; } + return s; +} + +// Resolves a row's composed named command to its runtime command id (0 if the action is +// not registered — e.g. a beta binary the row's family has not registered). The named- +// command lookup string is "_" + the channel-qualified id (REAPER's convention for +// extension-registered ids, per the SDK header's NamedCommandLookup note). +int resolveActionCommandId(const ActionButtonRow& row) { + if (!NamedCommandLookup) return 0; + const std::string named = "_" + channelCommandId(row.suffix); + return NamedCommandLookup(named.c_str()); +} + +// The button label for a row: action name + its current key binding, or the unbound +// marker. Queries kbd_getTextFromCmd in the MAIN section (SectionFromUniqueID(0)); a null +// / empty / blank return degrades to the unbound marker in the pure formatter. When the +// action is not registered (cmd == 0) the binding is treated as unbound. +std::string actionButtonLabel(const ActionButtonRow& row, int cmd) { + std::string binding; + if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) { + const char* text = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); + if (text) binding = text; + } + return formatButtonLabel(row.shortLabel, binding); +} + +// Draws the button strip: a filled band, a top divider, and each visible button with its +// binding label. Overflow (a narrow panel) HIDES the excess buttons — the pure layout +// returns only the buttons that fit at kButtonMinWidth, so nothing is drawn clipped. +void drawActionButtons(LICE_IBitmap* bmp, int w, int h) { + const ButtonStripRect strip = actionButtonStrip(w, h); + if (strip.height <= 0 || strip.width <= 0) return; + + LICE_FillRect(bmp, strip.x, strip.y, strip.width, strip.height, kColBtnStripBg, 1.0f, 0); + LICE_Line(bmp, strip.x, strip.y, strip.x + strip.width, strip.y, + kColBtnStripBorder, 1.0f, 0, false); + + const std::vector rows = actionButtonRows(); + const int n = static_cast(rows.size()); + const std::vector rects = + computeButtonRects(strip, n, kButtonMinWidth); + + HDC dc = bmp->getDC(); + for (const ActionButtonRect& r : rects) { + LICE_FillRect(bmp, r.x + 1, r.y + 2, r.width - 2, r.height - 4, + kColActionBtnBg, 1.0f, 0); + LICE_DrawRect(bmp, r.x + 1, r.y + 2, r.width - 2, r.height - 4, + kColActionBtnBorder, 1.0f, 0); + if (!dc) continue; + const ActionButtonRow& row = rows[static_cast(r.index)]; + const int cmd = resolveActionCommandId(row); + const std::string label = actionButtonLabel(row, cmd); + RECT rc{r.x + 4, r.y, r.x + r.width - 4, r.y + r.height}; + SetTextColor(dc, kRgbActionBtnText); + SetBkMode(dc, TRANSPARENT); + DrawText(dc, label.c_str(), -1, &rc, + DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); + } +} + +// Routes a click in the button strip to the hit button's action, fired through the +// command-id contract (Main_OnCommand with the runtime-resolved id — REAPER runs the SAME +// action a keybinding or the Actions list would). Returns true iff the click was inside +// the strip (handled or a harmless overflow-dead-zone / unregistered no-op), so the caller +// stops before grid handling. A cmd of 0 (action not registered) is a silent no-op. +bool handleActionButtonClick(int x, int y) { + if (!g_panel.hwnd) return false; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const ButtonStripRect strip = actionButtonStrip(w, h); + if (strip.height <= 0) return false; + + const std::vector rows = actionButtonRows(); + const int n = static_cast(rows.size()); + const int hit = hitTestButton(x, y, strip, n, kButtonMinWidth); + if (hit < 0) { + // Inside the strip band but not on a visible button (overflow dead-zone): claim + // the click so it never falls through to the grid. Outside the band: not ours. + return y >= strip.y && y < strip.y + strip.height && + x >= strip.x && x < strip.x + strip.width; + } + const int cmd = resolveActionCommandId(rows[static_cast(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 @@ -633,8 +799,11 @@ RECT splitBody(int w, int h) { rc.left = 0; rc.right = w; rc.top = kHeaderHeight; - const RECT footer = panelFooter(w, h); - rc.bottom = (footer.top < footer.bottom) ? footer.top : h; + // The body ends at the action-button strip (M11), which itself sits above the tail + // footer. When the strip collapses on a short client, actionButtonStrip returns its + // y at the footer top, so the body still ends at the footer edge. + const ButtonStripRect strip = actionButtonStrip(w, h); + rc.bottom = strip.y; if (rc.bottom < rc.top) rc.bottom = rc.top; return rc; } @@ -945,6 +1114,7 @@ void paintPanel(HWND hwnd, HDC hdc) { } drawModeSwitch(&bmp, w); + drawActionButtons(&bmp, w, h); // M11 button strip, above the footer drawTailFooter(&bmp, w, h); drawPruneButton(&bmp, w, h); // R3: raised over the footer strip @@ -1668,6 +1838,12 @@ void handleClick(int x, int y) { return; } + // Action-trigger button strip (M11): a click on a button fires the registered action + // via the command-id contract. Checked before the region chrome / grid so a strip + // click never selects a cell; the handler claims the whole strip band (a miss on the + // overflow dead-zone is a harmless no-op, not a fall-through to the grid below). + if (handleActionButtonClick(x, y)) return; + // Region chrome (headers, tab strip, buttons). if (poolShown()) { const RECT pr = poolRegionRect(w, h); diff --git a/tests/test_action_buttons.cpp b/tests/test_action_buttons.cpp new file mode 100644 index 0000000..ef75376 --- /dev/null +++ b/tests/test_action_buttons.cpp @@ -0,0 +1,236 @@ +// Standalone tests for reasampler::action_buttons — no REAPER, no test framework. +// Same fast loop as the sibling pure tests (mode_switch / tab_strip et al.): assert the +// action-button strip layout + hit-testing and the label-format logic directly. +// +// Covers (M11 brief §test cases): +// * Layout: N buttons in a strip — all fit (exact equal tiling), overflow on a narrow +// panel (only the fitting count laid out, rest hidden, never clipped), zero-width edge. +// * Hit-test: inside each button, outside the band, half-open boundary, the narrow-panel +// overflow dead-zone, hit/layout agreement. +// * Label format: bound ("name binding"), unbound marker, empty / whitespace-only SDK +// return -> unbound, over-long binding truncation. + +#include "../src/action_buttons.h" + +#include +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- Layout: all fit ---------------------------------------------------------- + +// Strip 300 wide at origin (0, 40), height 24, 3 buttons at min width 80: all fit +// (300/80 = 3), tiled equally -> boundaries floor(i*300/3) = 0,100,200,300, widths 100. +static void testAllButtonsFitEqualTiling() { + ButtonStripRect s{0, 40, 300, 24}; + ButtonFit fit = computeButtonFit(s, 3, 80); + CHECK(fit.visibleCount == 3); + CHECK(fit.hiddenCount == 0); + + auto rects = computeButtonRects(s, 3, 80); + CHECK(rects.size() == 3); + CHECK((rects[0] == ActionButtonRect{0, 0, 40, 100, 24})); + CHECK((rects[1] == ActionButtonRect{1, 100, 40, 100, 24})); + CHECK((rects[2] == ActionButtonRect{2, 200, 40, 100, 24})); + // Abut exactly; last reaches strip right edge. + CHECK(rects[1].x == rects[0].x + rects[0].width); + CHECK(rects[2].x + rects[2].width == s.x + s.width); +} + +// Uneven width absorbed at boundaries: 100 wide / 3 -> edges 0,33,66,100 -> 33,33,34. +static void testUnevenWidthTilesExactly() { + ButtonStripRect s{7, 3, 100, 16}; + auto rects = computeButtonRects(s, 3, 30); // 100/30 = 3 fit + CHECK(rects.size() == 3); + CHECK(rects[0].width == 33); + CHECK(rects[1].width == 33); + CHECK(rects[2].width == 34); + CHECK(rects.front().x == s.x); + CHECK(rects.back().x + rects.back().width == s.x + s.width); +} + +// --- Layout: overflow on a narrow panel --------------------------------------- + +// 5 buttons at min width 80 into a 200-wide strip: only 2 fit (200/80 = 2). The two +// visible buttons share the FULL strip (100 each — never clipped, never below min), and +// 3 are hidden (the overflow the shell degrades, not clipped garbage). +static void testOverflowHidesExcessNotClipped() { + ButtonStripRect s{0, 0, 200, 24}; + ButtonFit fit = computeButtonFit(s, 5, 80); + CHECK(fit.visibleCount == 2); + CHECK(fit.hiddenCount == 3); + + auto rects = computeButtonRects(s, 5, 80); + CHECK(rects.size() == 2); + CHECK(rects[0].width == 100); // >= min width 80, shares full strip + CHECK(rects[1].width == 100); + CHECK(rects[1].x + rects[1].width == s.x + s.width); +} + +// A strip too narrow for even one min-width button lays out nothing (all hidden). The +// shell draws an empty strip rather than a sub-minimum clipped button. +static void testStripTooNarrowForAny() { + ButtonStripRect s{0, 0, 50, 24}; + ButtonFit fit = computeButtonFit(s, 3, 80); + CHECK(fit.visibleCount == 0); + CHECK(fit.hiddenCount == 3); + CHECK(computeButtonRects(s, 3, 80).empty()); +} + +// --- Layout: degenerate -------------------------------------------------------- + +static void testLayoutDegenerate() { + CHECK(computeButtonRects(ButtonStripRect{0, 0, 300, 24}, 0, 80).empty()); + CHECK(computeButtonRects(ButtonStripRect{0, 0, 300, 24}, -2, 80).empty()); + CHECK(computeButtonRects(ButtonStripRect{0, 0, 0, 24}, 3, 80).empty()); + CHECK(computeButtonRects(ButtonStripRect{0, 0, -5, 24}, 3, 80).empty()); + CHECK(computeButtonRects(ButtonStripRect{0, 0, 300, 24}, 3, 0).empty()); + CHECK(computeButtonRects(ButtonStripRect{0, 0, 300, 24}, 3, -10).empty()); + + ButtonFit fit = computeButtonFit(ButtonStripRect{0, 0, 0, 24}, 3, 80); + CHECK(fit.visibleCount == 0 && fit.hiddenCount == 0); +} + +// A single button fills the whole strip. +static void testSingleButtonFillsStrip() { + ButtonStripRect s{5, 5, 120, 24}; + auto rects = computeButtonRects(s, 1, 80); + CHECK(rects.size() == 1); + CHECK((rects[0] == ActionButtonRect{0, 5, 5, 120, 24})); +} + +// --- Hit-test: hits ----------------------------------------------------------- + +static void testHitTestHitsEachButton() { + ButtonStripRect s{0, 40, 300, 24}; // 3 buttons, 100 wide each + CHECK(hitTestButton(0, 40, s, 3, 80) == 0); // top-left of button 0 + CHECK(hitTestButton(50, 51, s, 3, 80) == 0); // middle of button 0 + CHECK(hitTestButton(99, 63, s, 3, 80) == 0); // last pixel of button 0 + CHECK(hitTestButton(100, 50, s, 3, 80) == 1); // first pixel of button 1 + CHECK(hitTestButton(299, 40, s, 3, 80) == 2); // last column of button 2 +} + +// The boundary pixel belongs to exactly ONE button (half-open): px==100 starts button 1. +static void testHitTestBoundaryHalfOpen() { + ButtonStripRect s{0, 0, 300, 24}; + CHECK(hitTestButton(99, 10, s, 3, 80) == 0); + CHECK(hitTestButton(100, 10, s, 3, 80) == 1); + CHECK(hitTestButton(199, 10, s, 3, 80) == 1); + CHECK(hitTestButton(200, 10, s, 3, 80) == 2); +} + +// --- Hit-test: misses --------------------------------------------------------- + +static void testHitTestMissesOutsideBand() { + ButtonStripRect s{10, 40, 200, 24}; + CHECK(hitTestButton(9, 50, s, 3, 60) == -1); // left of strip + CHECK(hitTestButton(210, 50, s, 3, 60) == -1); // right edge (== x+width, excluded) + CHECK(hitTestButton(50, 39, s, 3, 60) == -1); // above the band + CHECK(hitTestButton(50, 64, s, 3, 60) == -1); // below the band +} + +// On a narrow panel the visible buttons fill the whole strip, so there is no in-band +// dead-zone; but when buttonCount is 0 or the strip too narrow, every in-band point +// misses (the shell draws nothing and ignores the click). +static void testHitTestOverflowDeadZone() { + ButtonStripRect s{0, 0, 50, 24}; // too narrow for any 80-min button + CHECK(hitTestButton(25, 10, s, 3, 80) == -1); + CHECK(hitTestButton(0, 0, s, 0, 80) == -1); // no buttons +} + +static void testHitTestDegenerate() { + ButtonStripRect s{0, 0, 300, 24}; + CHECK(hitTestButton(50, 10, s, 0, 80) == -1); + CHECK(hitTestButton(50, 10, s, -2, 80) == -1); + CHECK(hitTestButton(50, 10, ButtonStripRect{0, 0, 0, 24}, 3, 80) == -1); + CHECK(hitTestButton(50, 10, ButtonStripRect{0, 0, 300, 0}, 3, 80) == -1); +} + +// Every point in the strip hit-tests to the button that DREW it (hit-test and layout +// agree — the load-bearing consistency invariant), across an awkward width and count. +static void testHitTestMatchesLayout() { + ButtonStripRect s{4, 2, 173, 22}; + const int count = 4, minW = 40; // 173/40 = 4 fit + auto rects = computeButtonRects(s, count, minW); + for (int px = s.x; px < s.x + s.width; ++px) { + const int b = hitTestButton(px, s.y + 1, s, count, minW); + CHECK(b >= 0); + const ActionButtonRect& r = rects[static_cast(b)]; + CHECK(px >= r.x && px < r.x + r.width); + } +} + +// --- Label format ------------------------------------------------------------- + +static void testLabelBound() { + CHECK(formatButtonLabel("Capture Item", "Ctrl+Shift+C") == + "Capture Item Ctrl+Shift+C"); + CHECK(formatButtonLabel("Insert", "F3") == "Insert F3"); +} + +static void testLabelUnboundEmpty() { + CHECK(formatButtonLabel("Capture Item", "") == "Capture Item (unbound)"); +} + +// Whitespace-only SDK returns (spaces, tabs) collapse to the unbound marker — the +// explicit blank-return handling the brief requires. +static void testLabelUnboundBlank() { + CHECK(formatButtonLabel("Capture Track", " ") == "Capture Track (unbound)"); + CHECK(formatButtonLabel("Insert", "\t") == "Insert (unbound)"); + CHECK(formatButtonLabel("Insert", " \t ") == "Insert (unbound)"); +} + +// Leading/trailing whitespace on a real binding is trimmed before formatting. +static void testLabelTrimsSurroundingBlanks() { + CHECK(formatButtonLabel("Insert", " F3 ") == "Insert F3"); +} + +// An over-long binding is truncated to kMaxBindingChars-1 chars + "~" so the label stays +// bounded (a pathological multi-chord custom binding never blows the button budget). +static void testLabelTruncatesLongBinding() { + const std::string longB(40, 'X'); // 40 > kMaxBindingChars (24) + const std::string label = formatButtonLabel("Cancel", longB); + // "Cancel " (8) + 23 'X' + "~" (kMaxBindingChars total in the binding portion). + const std::string expectedBinding = + std::string(kMaxBindingChars - 1, 'X') + "~"; + CHECK(label == "Cancel " + expectedBinding); + CHECK(static_cast(expectedBinding.size()) == kMaxBindingChars); +} + +// A binding exactly at the limit is NOT truncated (boundary: <= kMaxBindingChars kept). +static void testLabelAtLimitNotTruncated() { + const std::string atLimit(kMaxBindingChars, 'Y'); + CHECK(formatButtonLabel("X", atLimit) == "X " + atLimit); +} + +int main() { + testAllButtonsFitEqualTiling(); + testUnevenWidthTilesExactly(); + testOverflowHidesExcessNotClipped(); + testStripTooNarrowForAny(); + testLayoutDegenerate(); + testSingleButtonFillsStrip(); + + testHitTestHitsEachButton(); + testHitTestBoundaryHalfOpen(); + testHitTestMissesOutsideBand(); + testHitTestOverflowDeadZone(); + testHitTestDegenerate(); + testHitTestMatchesLayout(); + + testLabelBound(); + testLabelUnboundEmpty(); + testLabelUnboundBlank(); + testLabelTrimsSurroundingBlanks(); + testLabelTruncatesLongBinding(); + testLabelAtLimitNotTruncated(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}