From fcd1ed022cec7b607dd1b39277cfc252ccecf5cf Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 3 Aug 2026 16:06:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(panel):=20add=20Insert=20as=20FX=20?= =?UTF-8?q?=E2=80=94=20ReaSampler=209000=20onto=20the=20selected=20track,?= =?UTF-8?q?=20preloaded=20with=20the=20focused=20capture,=20over=20the=20e?= =?UTF-8?q?xisting=20instrument-drop=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 3 +- src/app/main.cpp | 7 ++ src/core/ui/CLAUDE.md | 1 + src/core/ui/CMakeLists.txt | 7 +- src/core/ui/insert_fx_enable.cpp | 36 ++++++++++ src/core/ui/insert_fx_enable.h | 33 ++++++++++ src/shell/actions/CLAUDE.md | 1 + src/shell/actions/insert_fx_action.cpp | 54 +++++++++++++++ src/shell/actions/insert_fx_action.h | 14 ++++ src/shell/panel/CLAUDE.md | 4 +- src/shell/panel/panel_layout.cpp | 8 +++ src/shell/panel/panel_state.h | 7 +- tests/test_action_bar.cpp | 75 +++++++++++++++++---- tests/test_insert_fx_enable.cpp | 91 ++++++++++++++++++++++++++ 14 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 src/core/ui/insert_fx_enable.cpp create mode 100644 src/core/ui/insert_fx_enable.h create mode 100644 src/shell/actions/insert_fx_action.cpp create mode 100644 src/shell/actions/insert_fx_action.h create mode 100644 tests/test_insert_fx_enable.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index bfae2bd..0724ac9 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -52,9 +52,10 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/actions/arrange_drop_win.cpp ${REASAMPLER_SRC_DIR}/shell/actions/drag_out_win.cpp ${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp + ${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp ${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers) # NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The # instrument renders its own bake in its own process, which is what keeps the extension's # link graph free of the voice engine — a link edge to it here means the design drifted. diff --git a/src/app/main.cpp b/src/app/main.cpp index 140ca94..773d417 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -26,6 +26,7 @@ #include "shell/actions/action_registry.h" // the registration table #include "shell/actions/bank_actions.h" // multi-bank action family #include "shell/actions/design_view_actions.h" // Design View action family +#include "shell/actions/insert_fx_action.h" // insert-as-FX action body #include "shell/actions/package_export_action.h" // bank-package export action body #include "shell/actions/package_import_action.h" // bank-package import action body #include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id) @@ -87,6 +88,9 @@ static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session) static void RunInsertSelected(int arg) { capture::RunInsertSelected(g_session, arg != 0); } +// Placement of the PLAYER, not of an item: adds a ReaSampler 9000 to the selected track's +// FX chain. Sessionless — it reads the panel's selection, exactly like the drag it mirrors. +static void RunInsertAsFx(int) { reasampler::doInsertAsFx(); } static void RunBatchCaptureItems(int) { capture::RunBatchCaptureItems(g_session); } static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session); } static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); } @@ -126,6 +130,9 @@ static std::vector buildMainActionTable() { rows.push_back({"INSERT_SELECTED_CONFORM", "insert selected sample at edit cursor (conform to tempo)", &RunInsertSelected, 1}); + rows.push_back({"INSERT_AS_FX", + "insert selected sample as ReaSampler 9000 on the selected track", + &RunInsertAsFx}); // One action fires N captures (per selected item / per razor area); the original // selection is restored on every exit path. Bank-only, never places. rows.push_back({"CAPTURE_BATCH_ITEMS", diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index ac439e6..fdfdccd 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -104,6 +104,7 @@ L7 sub-pass, 2026-07-27): - `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement, plus `modeSegmentEnabled` — the mode segment's live/dead predicate under the playback gate AND under whether the shell resolved a routable command id for it (both bools passed IN, so this stays REAPER-free). - `overflow_menu` — pure overflow-menu-button geometry/reserve/hit-test for the top-toolbar More (⋯) button. - `mode_enable` — pure opposite-mode enablement predicate: given the active mode, computes per-button live/disabled state for the four Item/Track × Arrange/Design tag buttons. +- `insert_fx_enable` — pure refusal fold behind the Insert-as-FX verb (one instance holds one capture, so it needs exactly one focused capture AND a destination track), plus the button's live/dead bit derived from that same fold and one sentence per refusal. The payload/destination axes are ordered deliberately because they are knowable at different times — the header states which half the button may speak for. - `tooltip` — pure tooltip placement + prefix-strip: strips the `ReaSampler:` display prefix from the registered action phrase; width clamped to the client rect. - `card_drag` — pure drag-gesture precedence + slot hit-test: leave-client → OS drag-out; other-bank → move/copy; same-bank → reorder / Alt-over-occupied → replace. - `card_meta` — pure card-metadata formatters: bars.beats.subdivisions and seconds.milliseconds; blank when the sample is unstamped. Also `cardNameStrip` + the two strip constants — the card's name line sits across the TOP of the cell, drawn over the waveform exactly as the length read-out is over it at the bottom, and is suppressed entirely on a cell with no room for both strips plus a waveform band. diff --git a/src/core/ui/CMakeLists.txt b/src/core/ui/CMakeLists.txt index eea594f..78a7860 100644 --- a/src/core/ui/CMakeLists.txt +++ b/src/core/ui/CMakeLists.txt @@ -17,7 +17,12 @@ reasampler_pure_library(component_geometry SOURCES component_geometry.cpp) reasampler_test(component_geometry LINK component_geometry) reasampler_pure_library(action_bar SOURCES action_bar.cpp) -reasampler_test(action_bar LINK action_bar) +# overflow_menu supplies the More-button reserve the panel subtracts before tiling; the +# bar's narrow-width behaviour is only meaningful against that composition. +reasampler_test(action_bar LINK action_bar overflow_menu) + +reasampler_pure_library(insert_fx_enable SOURCES insert_fx_enable.cpp) +reasampler_test(insert_fx_enable LINK insert_fx_enable) # prune_button owns the FooterRect input type the footer layout reuses. reasampler_pure_library(footer_bar SOURCES footer_bar.cpp LINK PUBLIC prune_button) diff --git a/src/core/ui/insert_fx_enable.cpp b/src/core/ui/insert_fx_enable.cpp new file mode 100644 index 0000000..3396e65 --- /dev/null +++ b/src/core/ui/insert_fx_enable.cpp @@ -0,0 +1,36 @@ +// insert_fx_enable — pure implementation. See insert_fx_enable.h. + +#include "core/ui/insert_fx_enable.h" + +namespace reasampler::ui { + +InsertFxRefusal insertFxRefusal(int focusedCaptureCount, bool trackSelected) { + if (focusedCaptureCount <= 0) return InsertFxRefusal::NoCapture; + if (focusedCaptureCount > 1) return InsertFxRefusal::MultiCapture; + if (!trackSelected) return InsertFxRefusal::NoTrack; + return InsertFxRefusal::None; +} + +bool insertFxButtonEnabled(int focusedCaptureCount) { + return insertFxRefusal(focusedCaptureCount, /*trackSelected=*/true) == + InsertFxRefusal::None; +} + +std::string insertFxRefusalMessage(InsertFxRefusal refusal) { + switch (refusal) { + case InsertFxRefusal::None: + return {}; + case InsertFxRefusal::NoCapture: + return "select a capture in the bank panel first, then insert it as an " + "instrument on the selected track"; + case InsertFxRefusal::MultiCapture: + return "select exactly ONE capture -- a ReaSampler 9000 instance holds one " + "capture, so a multi-capture selection has no single sound to load"; + case InsertFxRefusal::NoTrack: + return "select a track first -- the instrument is added to the selected " + "track's FX chain"; + } + return {}; +} + +} // namespace reasampler::ui diff --git a/src/core/ui/insert_fx_enable.h b/src/core/ui/insert_fx_enable.h new file mode 100644 index 0000000..1da4feb --- /dev/null +++ b/src/core/ui/insert_fx_enable.h @@ -0,0 +1,33 @@ +#pragma once +// insert_fx_enable — refusal fold behind the Insert-as-FX verb (bank_panel Placement +// button + its bindable action). One ReaSampler 9000 instance holds exactly one capture, +// so the verb needs exactly one focused capture AND a destination track. + +#include + +namespace reasampler::ui { + +// Why the verb cannot run, or None. The two axes are deliberately ordered payload-first, +// because they are knowable at DIFFERENT times: the panel selection is authoritative at +// draw time, so the button paints dead for it; track selection lives in the project with +// no change callback, so it is only ever spoken as a message at press time. A live button +// therefore does NOT imply NoTrack was ruled out — every caller must run the full fold. +enum class InsertFxRefusal { + None, + NoCapture, + MultiCapture, + NoTrack, +}; + +InsertFxRefusal insertFxRefusal(int focusedCaptureCount, bool trackSelected); + +// The button's live/dead bit: the payload axis alone, derived from the same fold so the +// drawn state and the pressed outcome cannot disagree about the capture rules. +bool insertFxButtonEnabled(int focusedCaptureCount); + +// One sentence per refusal, prefix- and punctuation-free so the shell can frame it. The +// wording lives here rather than in the shell so "no silent refusal" is assertable +// without a DAW. None yields an empty string — nothing to say about a verb that ran. +std::string insertFxRefusalMessage(InsertFxRefusal refusal); + +} // namespace reasampler::ui diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index cd53922..257698d 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -43,6 +43,7 @@ is owned by other directories and only skinned here. - `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body. - `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. - `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** +- `insert_fx_action` — the "Insert as FX" verb: a second ENTRY POINT to `instrument_drop_win`'s drop body (the bank panel's Placement button and a bindable action), loading the panel's focused capture onto the first selected track. Owns no insert logic of its own — it resolves the selection, folds `core/ui/insert_fx_enable`, and calls `performInstrumentDrop` (which keeps the one undo block). Refuses visibly on every failing precondition; the panel button additionally paints dead for the two panel-local ones. - `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank. - `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless. - `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism. diff --git a/src/shell/actions/insert_fx_action.cpp b/src/shell/actions/insert_fx_action.cpp new file mode 100644 index 0000000..028b499 --- /dev/null +++ b/src/shell/actions/insert_fx_action.cpp @@ -0,0 +1,54 @@ +// insert_fx_action.cpp — see insert_fx_action.h. main.cpp owns the API pointers; this TU +// gets them extern via the WANT list. + +#include "shell/actions/insert_fx_action.h" + +#include +#include +#include + +#include "core/ui/insert_fx_enable.h" // the refusal fold + its wording +#include "core/wire/instrument_drop.h" // buildInstrumentDropPreset — the pure payload +#include "shell/actions/instrument_drop_win.h" // performInstrumentDrop — the shared drop body +#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds + +#include "reaper_plugin.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetSelectedTrack +#define REAPERAPI_WANT_ShowConsoleMsg +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +void report(const std::string& sentence) { + if (ShowConsoleMsg) ShowConsoleMsg(("ReaSampler: " + sentence + "\n").c_str()); +} + +} // namespace + +void doInsertAsFx() { + const std::vector selected = bankPanelSelectedSampleIds(); + // seltrackidx 0 = the FIRST selected track, master excluded (SDK header) — the same + // "the selected track" the Media-Explorer ingest surface targets. A multi-track + // selection therefore loads onto the first, rather than refusing or fanning out. + MediaTrack* track = GetSelectedTrack ? GetSelectedTrack(nullptr, 0) : nullptr; + + const ui::InsertFxRefusal refusal = + ui::insertFxRefusal(static_cast(selected.size()), track != nullptr); + if (refusal != ui::InsertFxRefusal::None) { + report(ui::insertFxRefusalMessage(refusal)); + return; + } + + // performInstrumentDrop opens its own undo block (one Ctrl-Z) and rolls the FX back + // itself if the preset never applies — there is nothing here to group with it, so no + // second block is opened around it. + if (!performInstrumentDrop(track, wire::buildInstrumentDropPreset(selected.front()))) + report("could not add ReaSampler 9000 to the selected track -- check the plug-in " + "is installed and scanned"); +} + +} // namespace reasampler diff --git a/src/shell/actions/insert_fx_action.h b/src/shell/actions/insert_fx_action.h new file mode 100644 index 0000000..a3ca877 --- /dev/null +++ b/src/shell/actions/insert_fx_action.h @@ -0,0 +1,14 @@ +#pragma once +// insert_fx_action — the "Insert as FX" verb: a ReaSampler 9000 instance on the SELECTED +// track, preloaded with the bank panel's focused capture. A second ENTRY POINT to +// instrument_drop_win's drop body — reached by a button or a keybinding instead of a +// drag — never a second insert path, so that body's undo block and its +// never-capture/never-place guarantees carry over unchanged. + +namespace reasampler { + +// Refuses visibly on every failing precondition (no capture / many captures / no track): +// see core/ui/insert_fx_enable for the fold and its wording. +void doInsertAsFx(); + +} // namespace reasampler diff --git a/src/shell/panel/CLAUDE.md b/src/shell/panel/CLAUDE.md index 0cc9763..15dae0c 100644 --- a/src/shell/panel/CLAUDE.md +++ b/src/shell/panel/CLAUDE.md @@ -10,8 +10,8 @@ VST3 editor (`shell/instrument/`). Pure layout/hit-test/palette modules the panel draws through (`theme`, `component_geometry`, `bank_grid`, `tab_strip`, `mode_switch`, `action_bar`, -`footer_bar`, `overflow_menu`, `prune_button`, `mode_enable`, `tooltip`, `card_drag`, -`card_meta`) live in `core/ui` / `core/model` and are documented there — this +`footer_bar`, `overflow_menu`, `prune_button`, `mode_enable`, `insert_fx_enable`, +`tooltip`, `card_drag`, `card_meta`) live in `core/ui` / `core/model` and are documented there — this directory consumes them but does not own them. The promptless bank-mutation verbs (`bankOpCreate`/`Rename`/`Delete`/… + `persistBankOp`) that `panel_bank_ops` skins live in `shell/bank_ops`, a sibling directory, not here. diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp index 1a3de78..5bdbc72 100644 --- a/src/shell/panel/panel_layout.cpp +++ b/src/shell/panel/panel_layout.cpp @@ -128,6 +128,14 @@ std::vector topBarRows() { rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", "insert selected sample at edit cursor (conform to tempo)", ActionCluster::Placement, true}); + // The one top-bar row with a live/dead gate: it places the PLAYER, and one instance + // holds one capture. Only the payload half of the fold is knowable at draw time — + // core/ui/insert_fx_enable.h states why the track half stays a press-time message. + rows.push_back({"INSERT_AS_FX", "Insert as FX", + "insert selected sample as ReaSampler 9000 on the selected track", + ActionCluster::Placement, + insertFxButtonEnabled( + static_cast(g_panel.selection.indices.size()))}); return rows; } diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 7abebf8..99a908d 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -41,6 +41,7 @@ #include "core/ui/component_geometry.h" #include "core/ui/drag_out.h" #include "core/ui/footer_bar.h" +#include "core/ui/insert_fx_enable.h" #include "core/ui/mode_enable.h" #include "core/ui/overflow_menu.h" #include "core/ui/prune_button.h" @@ -133,6 +134,7 @@ using ui::hitTestMenuButton; using ui::hitTestPruneButton; using ui::hitTestSlot; using ui::hitTestTabStrip; +using ui::insertFxButtonEnabled; using ui::kCardNameScrimAlpha; using ui::kCardStripHeight; using ui::kCardStripPad; @@ -401,8 +403,9 @@ struct ActionBarRow { std::string shortLabel; std::string fullName; ActionCluster cluster = ActionCluster::Capture; - bool enabled = true; // opposite-mode gate for bottom-bar tag buttons; always true - // for the top bar (unconditional triggers) + bool enabled = true; // opposite-mode gate for the bottom-bar tag buttons, and the + // payload gate for the top bar's Insert as FX; every other top- + // bar row is an unconditional trigger and stays true }; // Modifier state at event time. Alt = the replace modifier. diff --git a/tests/test_action_bar.cpp b/tests/test_action_bar.cpp index 7eba7b1..ad651c2 100644 --- a/tests/test_action_bar.cpp +++ b/tests/test_action_bar.cpp @@ -6,7 +6,8 @@ // * Layout: correct rects for each action button across representative panel widths; buttons // pack at a fixed width with intra-cluster + inter-cluster gaps. // * Overflow/hiding when the bar is too narrow (whole trailing buttons dropped, never -// clipped; earlier frequent clusters survive). +// clipped; earlier frequent clusters survive), and the More-button reserve the panel +// subtracts before tiling (composed here with overflow_menu, as panel_layout does). // * Label sub-rect correct — spans full button height (L6: keybinding sub-row removed from // the face; binding is in the hover tooltip instead). // * Task grouping reflected STRUCTURALLY: each slot carries its cluster; the flat index runs @@ -16,6 +17,7 @@ // * Resize: no inventory item cut off or overlapping across a representative width range. #include "../src/core/ui/action_bar.h" +#include "../src/core/ui/overflow_menu.h" #include #include @@ -28,14 +30,15 @@ static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) -// The panel's real inventory shape (L6): 2 capture, 1 maintenance (Re-capture), 2 placement = 5 -// buttons. Cluster order: Capture -> Maintenance -> Placement (Re-capture sits between the two -// capture verbs and the placement verbs — the L6 bar ordering). +// The panel's real inventory shape: 2 capture, 1 maintenance (Re-capture), 3 placement +// (Insert / Insert Conform / Insert as FX) = 6 buttons. Cluster order: +// Capture -> Maintenance -> Placement (Re-capture sits between the two capture verbs and the +// placement verbs). static std::vector inventory() { return { {ActionCluster::Capture, 2}, {ActionCluster::Maintenance, 1}, - {ActionCluster::Placement, 2}, + {ActionCluster::Placement, 3}, }; } @@ -52,7 +55,7 @@ static ActionBarSpec roundSpec() { // --- Layout: all fit, correct rects + gaps ------------------------------------ -// A wide bar fits all 5 buttons. Verify the first few rects, the intra-cluster gap, and the +// A wide bar fits all 6 buttons. Verify the first few rects, the intra-cluster gap, and the // (wider) inter-cluster gap between button 1 (last capture) and button 2 (maintenance). // // Pixel walk with roundSpec and bar at (0,40): @@ -61,18 +64,19 @@ static ActionBarSpec roundSpec() { // btn2 (Maintenance): x=228, right=328 (clusterGap = 16 after btn1's right) // btn3 (Placement): x=344, right=444 (clusterGap = 16 after btn2's right) // btn4 (Placement): x=448, right=548 (intraGap = 4 after btn3's right) -// Minimum bar width: 548 + sidePad(8) = 556. Give it 600. +// btn5 (Placement): x=552, right=652 (intraGap = 4 after btn4's right) +// Minimum bar width: 652 + sidePad(8) = 660. Give it 700. static void testAllFitRectsAndGaps() { const auto clusters = inventory(); const ActionBarSpec spec = roundSpec(); - ActionBarRect bar{0, 40, 600, 34}; + ActionBarRect bar{0, 40, 700, 34}; BarFit fit = computeBarFit(bar, clusters, spec); - CHECK(fit.visibleCount == 5); + CHECK(fit.visibleCount == 6); CHECK(fit.hiddenCount == 0); auto slots = computeBarSlots(bar, clusters, spec); - CHECK(slots.size() == 5); + CHECK(slots.size() == 6); // Button 0: at sidePad, top = y + verticalInset, height = barH - 2*inset. CHECK(slots[0].x == 8); @@ -101,6 +105,13 @@ static void testAllFitRectsAndGaps() { CHECK(slots[4].cluster == ActionCluster::Placement); CHECK(slots[4].index == 4); + // Button 5 (Insert as FX — third placement): intra-cluster gap of 4 after 548 -> 552. + // It joins the Placement cluster, so no cluster gap opens before it. + CHECK(slots[5].x == 552); + CHECK(slots[5].cluster == ActionCluster::Placement); + CHECK(slots[5].index == 5); + CHECK(slots[5].x - (slots[4].x + slots[4].width) == spec.buttonGap); + // Inter-cluster gap (slot[2].x - slot[1].right = 228 - 212 = 16) is wider than the // intra-cluster gap (slot[1].x - slot[0].right = 112 - 108 = 4) — task grouping is // structurally visible in the geometry. @@ -155,7 +166,7 @@ static void testOverflowDropsTrailingWhole() { ActionBarRect bar{0, 0, 220, 34}; BarFit fit = computeBarFit(bar, clusters, spec); CHECK(fit.visibleCount == 2); - CHECK(fit.hiddenCount == 3); + CHECK(fit.hiddenCount == 4); auto slots = computeBarSlots(bar, clusters, spec); CHECK(slots.size() == 2); @@ -175,10 +186,49 @@ static void testTooNarrowForAny() { ActionBarRect bar{0, 0, 60, 34}; // sidePad*2 + one 100-wide button won't fit BarFit fit = computeBarFit(bar, clusters, spec); CHECK(fit.visibleCount == 0); - CHECK(fit.hiddenCount == 5); + CHECK(fit.hiddenCount == 6); CHECK(computeBarSlots(bar, clusters, spec).empty()); } +// --- Overflow reserve: the bar never tiles under the More (...) button -------- + +// The panel hands action_bar the top band MINUS overflow_menu's reserve (panel_layout's +// topToolbarActionRect). Reproduce that composition and assert the invariant it exists to +// buy: across every width, no visible button's right edge crosses into the reserved strip +// where the More button is drawn — including the widths where the reserve is what pushes +// the trailing "Insert as FX" button into overflow. +static void testOverflowReserveHonouredAcrossWidths() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + const MenuButtonSpec menuSpec; // the panel's kMenuBtnSpec defaults + + bool sawReserveCostAButton = false; + for (int w = 40; w <= 900; w += 3) { + const MenuBarRect band{0, 0, w, 34}; + const int reserve = menuButtonReserve(band, menuSpec); + CHECK(reserve >= 0); + + ActionBarRect action{0, 0, w - reserve, 34}; + if (action.width < 0) action.width = 0; + + const MenuButtonRect more = computeMenuButton(band, menuSpec); + for (const auto& s : computeBarSlots(action, clusters, spec)) { + // Fully clear of the reserved strip at the band's right. + CHECK(s.x + s.width <= w - reserve); + // And so, transitively, clear of the More button itself when one is drawn. + if (!more.empty()) CHECK(s.x + s.width <= more.x); + } + + // The reserve genuinely costs buttons somewhere in this range, so the assertions + // above are exercised against a bar the reserve actually narrowed. + const int full = computeBarFit(ActionBarRect{0, 0, w, 34}, clusters, spec).visibleCount; + const int reserved = computeBarFit(action, clusters, spec).visibleCount; + CHECK(reserved <= full); + if (reserved < full) sawReserveCostAButton = true; + } + CHECK(sawReserveCostAButton); +} + // --- Degenerate -------------------------------------------------------------- static void testDegenerate() { @@ -305,6 +355,7 @@ int main() { testLabelFullHeightWhenShort(); testOverflowDropsTrailingWhole(); testTooNarrowForAny(); + testOverflowReserveHonouredAcrossWidths(); testDegenerate(); testHitTestHitsButtons(); testHitTestGapsAreMisses(); diff --git a/tests/test_insert_fx_enable.cpp b/tests/test_insert_fx_enable.cpp new file mode 100644 index 0000000..24ff219 --- /dev/null +++ b/tests/test_insert_fx_enable.cpp @@ -0,0 +1,91 @@ +// Standalone tests for reasampler::ui::insert_fx_enable — no REAPER, no test framework. +// Exhaustive over the fold's whole input space (track selected x capture-count class), +// plus the two properties the shell leans on: the button bit is the fold restricted to +// the payload axis, and every refusal carries a non-empty sentence (a silent refusal is +// the failure mode this verb must not have). + +#include "../src/core/ui/insert_fx_enable.h" + +#include +#include + +using namespace reasampler::ui; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// All six combinations of {no track, track} x {0, 1, many captures}. The payload axis is +// checked BEFORE the destination axis, so a press with neither a capture nor a track +// reports the capture problem — the one the user can fix inside the panel they clicked. +static void testEveryCombination() { + CHECK(insertFxRefusal(0, false) == InsertFxRefusal::NoCapture); + CHECK(insertFxRefusal(0, true) == InsertFxRefusal::NoCapture); + CHECK(insertFxRefusal(1, false) == InsertFxRefusal::NoTrack); + CHECK(insertFxRefusal(1, true) == InsertFxRefusal::None); + CHECK(insertFxRefusal(3, false) == InsertFxRefusal::MultiCapture); + CHECK(insertFxRefusal(3, true) == InsertFxRefusal::MultiCapture); +} + +// Exactly one capture is the only count that can run; two is already too many. +static void testOnlyASingleCaptureRuns() { + CHECK(insertFxRefusal(2, true) == InsertFxRefusal::MultiCapture); + for (int n = 0; n <= 8; ++n) { + const bool ready = insertFxRefusal(n, true) == InsertFxRefusal::None; + CHECK(ready == (n == 1)); + } +} + +// A negative count can only come from a caller bug; it must fold to a refusal, never to +// Ready (fail closed — this verb writes to the project). +static void testNegativeCountRefuses() { + CHECK(insertFxRefusal(-1, true) == InsertFxRefusal::NoCapture); + CHECK(!insertFxButtonEnabled(-1)); +} + +// The button bit is the fold with the destination axis held satisfied: it goes dead for +// exactly the two panel-local refusals and stays live otherwise, whatever the project's +// track selection happens to be. +static void testButtonBitIsThePayloadAxisOfTheFold() { + for (int n = -1; n <= 4; ++n) { + const bool live = insertFxButtonEnabled(n); + CHECK(live == (insertFxRefusal(n, true) == InsertFxRefusal::None)); + // A dead button always corresponds to a capture-side refusal, never to NoTrack. + if (!live) { + const InsertFxRefusal r = insertFxRefusal(n, false); + CHECK(r == InsertFxRefusal::NoCapture || r == InsertFxRefusal::MultiCapture); + } + } + CHECK(insertFxButtonEnabled(1)); + CHECK(!insertFxButtonEnabled(0)); + CHECK(!insertFxButtonEnabled(2)); +} + +// Every refusal says something, and each says something DIFFERENT — a shared sentence +// would leave the user unable to tell which condition they hit. None says nothing. +static void testEveryRefusalHasItsOwnSentence() { + const std::string none = insertFxRefusalMessage(InsertFxRefusal::None); + const std::string noCap = insertFxRefusalMessage(InsertFxRefusal::NoCapture); + const std::string multi = insertFxRefusalMessage(InsertFxRefusal::MultiCapture); + const std::string noTrk = insertFxRefusalMessage(InsertFxRefusal::NoTrack); + + CHECK(none.empty()); + CHECK(!noCap.empty()); + CHECK(!multi.empty()); + CHECK(!noTrk.empty()); + CHECK(noCap != multi); + CHECK(noCap != noTrk); + CHECK(multi != noTrk); +} + +int main() { + testEveryCombination(); + testOnlyASingleCaptureRuns(); + testNegativeCountRefuses(); + testButtonBitIsThePayloadAxisOfTheFold(); + testEveryRefusalHasItsOwnSentence(); + + if (g_fail == 0) std::printf("insert_fx_enable: all tests passed\n"); + else std::printf("insert_fx_enable: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}