diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index 9df8acd..bfc5a22 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -107,7 +107,7 @@ L7 sub-pass, 2026-07-27): - `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure. - `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers. - `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching). -- `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). +- `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) — plus `footerReadout`, which decides what the footer's ONE passive readout box says: a MOVING background FX drain pre-empts the per-mode member count, since the drain is transient and the count is not. Its `FxDrainState` mirrors the shell's `FxReclaimActivity` name-for-name (a shell header cannot be included here); the shell owns the decision, this owns only the wording, and the two directions word differently because parking and restoring are different acts. - `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. diff --git a/src/core/ui/footer_bar.cpp b/src/core/ui/footer_bar.cpp index a424b2b..7d59f42 100644 --- a/src/core/ui/footer_bar.cpp +++ b/src/core/ui/footer_bar.cpp @@ -83,4 +83,15 @@ bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routab return isActiveSegment || (!transportRunning && routable); } +std::string footerReadout(FxDrainState drain, int activeModeMembers) { + // Enumerated rather than defaulted so a fourth state has to be answered here instead + // of inheriting the count by accident. + switch (drain) { + case FxDrainState::Online: return kFxRestoringReadout; + case FxDrainState::Offline: return kFxParkingReadout; + case FxDrainState::Idle: break; + } + return std::to_string(activeModeMembers) + (activeModeMembers == 1 ? " track" : " tracks"); +} + } // namespace reasampler::ui diff --git a/src/core/ui/footer_bar.h b/src/core/ui/footer_bar.h index 7142c5b..2aa03a7 100644 --- a/src/core/ui/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -13,6 +13,8 @@ // (computeSegmentRects / hitTestSegment) for per-segment tiling — mode_switch stays the one // owner of segment geometry. +#include + #include "core/ui/prune_button.h" // FooterRect — the shared footer strip input type namespace reasampler::ui { @@ -79,4 +81,25 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout); // `core/ui` — stays REAPER-free, exactly as `transportRunning` is. bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable); +// Which way the background FX drain is moving, as the readout needs to hear it. Declared +// here rather than taken from the shell's FxReclaimActivity because `core/ui` may not +// include a shell header; the member names are deliberately the shell's, so the one map +// between them (panel_input) is a name-for-name switch and a drift shows up as one. +// Idle is "nothing moving", never "no work queued" — the shell's enum owns that reasoning. +enum class FxDrainState { Idle, Online, Offline }; + +// DRAFT wording, pending sign-off. Short on purpose: these share the count box, whose +// default 64px holds roughly a dozen glyphs at the panel's smallest font before the +// shell's kit end-ellipsizes it. The two directions read differently because they ARE +// different acts — Offline takes the inactive modes' chains out, Online brings the +// entering mode's back. +inline constexpr const char* kFxParkingReadout = "parking FX"; +inline constexpr const char* kFxRestoringReadout = "restoring FX"; + +// The footer's ONE passive readout. A moving FX drain pre-empts the per-mode member count +// — the drain is transient and the count is not, so it borrows the slot the user already +// reads rather than earning a second box that this footer's greedy layout would be the +// first to drop on a narrow panel. +std::string footerReadout(FxDrainState drain, int activeModeMembers); + } // namespace reasampler::ui diff --git a/src/shell/panel/CLAUDE.md b/src/shell/panel/CLAUDE.md index 441a355..1126f2b 100644 --- a/src/shell/panel/CLAUDE.md +++ b/src/shell/panel/CLAUDE.md @@ -62,6 +62,19 @@ live in `shell/bank_ops`, a sibling directory, not here. - **No external UI framework.** iPlug2 / JUCE / VSTGUI are rejected (DS-1). LICE + reused WDL pieces are the toolkit; reject any path that pulls in a new framework. +- **The FX-drain indicator asks `fxReclaimActivity()` and decides nothing itself.** + `panel_input` polls it once per tick into `g_panel.fxDrain`; the footer readout and the + `WM_SETCURSOR` busy cursor both read that cached state, so paint and cursor cannot + disagree within a tick. The transport gate, the park debounce and the open-undo-block + stand-off are all already folded into that answer, against the reclaim queue's own pinned + project — do not re-evaluate any of them here, and never drive this off `fxReclaimPending` + (true throughout the debounce, when only the opposite direction is eligible). + The indicator reaches only our own window: over REAPER's arrange or mixer the cursor is + REAPER's, and the SDK exposes nothing host-wide. A global `SetSystemCursor` or a message + hook would reach further and is rejected — do not reintroduce either. `IDC_APPSTARTING` + is Windows-only (SWELL ships no equivalent), so on mac/Linux the readout is the whole + indicator. `[verify — DAW]` whether REAPER's docker even routes `WM_SETCURSOR` to the + docked child dialog at all — the whole Windows half of this rests on that. - **No hit-test geometry in untestable shell code.** Even when reusing a WDL piece (e.g. a `vwnd` control for a long scroll list), layout/hit-test math stays in pure CTest-covered modules — do not import a WDL control's retained-mode object model diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index d2876e5..7c73ec2 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -20,6 +20,7 @@ #include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag #include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam #include "shell/capture/track_guid.h" // guidString — canonical track GUID key +#include "shell/view/fx_reclaim.h" // fxReclaimActivity — the drain-direction read #include "shell/view/view.h" // transportBlocksModeSwitch #include "shell/view/view_lanes.h" // mintManagedLanes @@ -476,6 +477,35 @@ int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) { accelerator_register_t g_accel{translateAccel, true, nullptr}; bool g_accelRegistered = false; +// The ONE map from the reclaimer's answer to the pure readout's vocabulary. Name-for-name +// so a divergence reads as one; the decision itself is entirely fxReclaimActivity's. +// This TU compiles straight into the reaper_reasampler MODULE, which carries none of +// reasampler_pure_library's -Werror=switch/we4062 promotion (footer_bar's mirror switch +// over FxDrainState has that; this one didn't) — a fourth FxReclaimActivity enumerator +// would fall through to Idle here with no diagnostic at all. Promoted locally instead of +// widening the target-wide flag, which would need auditing every switch already compiled +// into this MODULE for an intentionally-partial case list. +#if defined(_MSC_VER) +#pragma warning(push) +#pragma warning(error : 4062) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic error "-Wswitch" +#endif +FxDrainState drainStateNow() { + switch (fxReclaimActivity()) { + case FxReclaimActivity::Online: return FxDrainState::Online; + case FxReclaimActivity::Offline: return FxDrainState::Offline; + case FxReclaimActivity::Idle: break; + } + return FxDrainState::Idle; +} +#if defined(_MSC_VER) +#pragma warning(pop) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif + } // namespace void registerAccel() { @@ -530,6 +560,15 @@ void bankPanelRefresh() { InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); } + // The drain finishes without a callback, so its direction is polled here alongside the + // transport and repainted only on an edge. + const panel::FxDrainState drain = panel::drainStateNow(); + if (drain != panel::g_panel.fxDrain) { + panel::g_panel.fxDrain = drain; + panel::refreshBusyCursor(); + InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); + } + // The custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if // a toolbar button has rested under the pointer past the delay, latch + repaint it. panel::maybeShowTooltip(); diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index 0f2ca20..0696908 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -147,13 +147,15 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) { } } - // Per-mode member count, a compact dim readout beside the toggle. Passive text, not a control. + // The one passive readout beside the toggle: a moving FX drain, else the per-mode member + // count (footerReadout owns which). Passive text, not a control. The drain draws + // text/primary against the count's text/dim so a transient state reads as one. if (!fb.count.empty()) { - const int members = activeModeMemberCount(); - const std::string countLabel = - std::to_string(members) + (members == 1 ? " track" : " tracks"); + const bool draining = g_panel.fxDrain != FxDrainState::Idle; + const std::string label = footerReadout(g_panel.fxDrain, activeModeMemberCount()); kitText(bmp, KitBox{fb.count.x, fb.count.y, fb.count.width, fb.count.height}, - countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center); + label.c_str(), Font::Micro, + draining ? Role::TextPrimary : Role::TextDim, Align::Center); } // Tail BUTTON — a real kit button with rest/hover states; its click cycles the tail diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index ed96f71..2a77e79 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -83,6 +83,7 @@ using ui::FooterBarLayout; using ui::FooterBarSpec; using ui::FooterHit; using ui::FooterRect; +using ui::FxDrainState; using ui::GridSpec; using ui::InteractionState; using ui::KitBox; @@ -127,6 +128,7 @@ using ui::cueForDropClass; using ui::cursorForGesture; using ui::decideCardGesture; using ui::decideDropClass; +using ui::footerReadout; using ui::formatBarsBeats; using ui::formatSecondsMs; using ui::hitTestActionBar; @@ -322,6 +324,12 @@ struct PanelState { // within a tick. bool modeSwitchBlocked = false; + // Which direction the background FX reclaimer is MOVING, polled on the same tick as + // modeSwitchBlocked. The footer readout and the busy cursor both read THIS rather than + // polling again, so the pixel the user saw and the cursor under their pointer cannot + // disagree within a tick. + FxDrainState fxDrain = FxDrainState::Idle; + BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; // The named bank the banks region shows — distinct from the active/capture-target @@ -442,6 +450,11 @@ inline void invalidatePanel() { // Cross-seam contract, grouped by owning TU; all plain free functions. +// panel_window.cpp — re-resolves the pointer's cursor after g_panel.fxDrain changed. The +// window answers WM_SETCURSOR from that state, but the OS only re-asks on pointer +// movement, so a drain that starts or ends under a still pointer needs this nudge. +void refreshBusyCursor(); + // panel_bank_ops.cpp — book/bank accessors + the bank-CRUD verbs + menus. BankBook* book(); const BankModel* indexForRegion(Region r); diff --git a/src/shell/panel/panel_window.cpp b/src/shell/panel/panel_window.cpp index 12a188d..3282a08 100644 --- a/src/shell/panel/panel_window.cpp +++ b/src/shell/panel/panel_window.cpp @@ -94,8 +94,53 @@ void handleDropFiles(HDROP hDrop) { if (!paths.empty()) ingestDroppedFiles(paths); } +// IDC_APPSTARTING — arrow plus hourglass, "busy in the background, still usable" — is the +// exact reading for the FX reclaimer's drain, and IDC_WAIT is not: nothing here blocks. +// SWELL defines neither anywhere in vendor/WDL/WDL/swell, so on mac/Linux there is no busy +// cursor at all and the footer readout carries the state alone. The cursor reaches OUR +// windows only; REAPER's arrange and mixer answer their own WM_SETCURSOR and the SDK +// exposes nothing host-wide to ask for. A global SetSystemCursor or a message hook would +// reach further and is deliberately not done. [verify — DAW] whether REAPER's docker even +// routes WM_SETCURSOR to the docked child dialog at all — the premise the whole Windows +// half of this feature rests on. +#ifdef _WIN32 +HCURSOR busyCursor() { return LoadCursor(nullptr, IDC_APPSTARTING); } +#else +HCURSOR busyCursor() { return nullptr; } +#endif + +// A pure read of the polled drain state at query time, never a latched mode — which is what +// makes it clear itself on every path the drain can end by. A live card drag outranks it: +// that cue tells the user what a release will DO, and is the more urgent read. +bool busyCursorApplies() { + return busyCursor() && g_panel.fxDrain != FxDrainState::Idle && !g_panel.dragArmed && + !g_panel.dragging; +} + WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { + case WM_SETCURSOR: + // Non-client hits (resize border, etc.) are not ours to answer for; let the + // default handling run. Harmless today (IDD_BANK_PANEL is a bare WS_CHILD + // with no controls or border) but the guard is the conventional one. + if (LOWORD(lParam) != HTCLIENT) return 0; + // Claiming the message (non-zero) is what stops the default arrow from being + // reapplied underneath us — on Win32 via DefDlgProc, on SWELL via the same + // return test in swell-wnd-generic.cpp. Falling through leaves every other + // cursor decision exactly where it was (panel_drag's per-move SetCursor). + if (busyCursorApplies()) { + SetCursor(busyCursor()); + // A DLGPROC conveys its result through DWLP_MSGRESULT, not the raw + // return value; WM_SETCURSOR is not on the exception-message list that + // skips this. Without it the bare `return 1` still suppresses + // DefDlgProc's own cursor (SWELL checks the raw return too), but a + // parent or sender reading the result would see "unhandled". +#ifdef _WIN32 + SetWindowLongPtr(hwnd, DWLP_MSGRESULT, TRUE); +#endif + return 1; + } + return 0; case WM_DROPFILES: handleDropFiles(reinterpret_cast(wParam)); return 0; @@ -146,6 +191,9 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { resetDragState(); g_panel.hovered = Hover{}; g_panel.tooltipShown = false; + // Nothing polls the drain while the panel is closed, so a stale non-Idle here + // would be the first thing a reopened panel painted. + g_panel.fxDrain = FxDrainState::Idle; g_panel.hwnd = nullptr; g_panel.open = false; return 0; @@ -205,6 +253,19 @@ void closePanel() { } // namespace +void refreshBusyCursor() { + // Nothing to refresh on mac/Linux (busyCursor() is always null there), and a live + // drag owns the cursor already (panel_drag::applyDragCursor) — forcing IDC_ARROW + // here on a drain edge mid-drag would stomp that cue until the next mouse move. + if (!g_panel.hwnd || !busyCursor() || g_panel.dragArmed || g_panel.dragging) return; + POINT pt{}; + GetCursorPos(&pt); // SWELL's returns void, so its result is deliberately not tested + // Only when our own window is the one under the pointer: a menu or another window + // covering the panel owns the cursor there, and setting one from here would fight it. + if (WindowFromPoint(pt) != g_panel.hwnd) return; + SetCursor(busyCursorApplies() ? busyCursor() : LoadCursor(nullptr, IDC_ARROW)); +} + } // namespace reasampler::panel namespace reasampler { diff --git a/tests/test_footer_bar.cpp b/tests/test_footer_bar.cpp index 8544e4a..6cae30e 100644 --- a/tests/test_footer_bar.cpp +++ b/tests/test_footer_bar.cpp @@ -12,10 +12,13 @@ // * countWidth <= 0 hides the count label and the Tail sits right after the toggle. // * Hit-test: Toggle / Tail returned for in-bounds points, None outside AND on the count // label (a passive readout, never a control); half-open bounds; suppressed box claims none. +// * Readout: the count and its pluralization at Idle, each drain direction's pre-emption +// of it, the two directions reading differently, and both strings fitting the box. #include "../src/core/ui/footer_bar.h" #include +#include using namespace reasampler; using namespace reasampler::ui; @@ -259,6 +262,49 @@ static void testActiveSegmentStaysLiveEvenWhenUnroutable() { CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/false)); } +// --- The readout: count, and the drain's pre-emption of it ------------------- + +static void testIdleReadsTheCountAndNeitherDrainString() { + // Idle is the whole no-work-moving family — an empty queue, an item inside its park + // debounce, one held by the transport, one held by an open undo block. The readout + // cannot tell them apart and must show the count in every one of them. + CHECK(footerReadout(FxDrainState::Idle, 0) == "0 tracks"); + CHECK(footerReadout(FxDrainState::Idle, 1) == "1 track"); + CHECK(footerReadout(FxDrainState::Idle, 2) == "2 tracks"); + CHECK(footerReadout(FxDrainState::Idle, 4) != kFxParkingReadout); + CHECK(footerReadout(FxDrainState::Idle, 4) != kFxRestoringReadout); +} + +static void testEachDirectionPreEmptsTheCount() { + // The drain wins the slot at every count, including the singular that would otherwise + // read differently — a caller cannot land on the count text while one is moving. + CHECK(footerReadout(FxDrainState::Online, 0) == kFxRestoringReadout); + CHECK(footerReadout(FxDrainState::Online, 1) == kFxRestoringReadout); + CHECK(footerReadout(FxDrainState::Offline, 1) == kFxParkingReadout); + CHECK(footerReadout(FxDrainState::Offline, 7) == kFxParkingReadout); + // Independent of what the constants happen to say: neither direction may render the + // idle box, or the indicator would be invisible however they are later reworded. + CHECK(footerReadout(FxDrainState::Online, 1) != footerReadout(FxDrainState::Idle, 1)); + CHECK(footerReadout(FxDrainState::Offline, 4) != footerReadout(FxDrainState::Idle, 4)); +} + +static void testTheTwoDirectionsReadDifferently() { + // Restoring the entering mode's chains and parking the leaving mode's are different + // acts; one shared word for both would misname whichever it was not written for. + CHECK(footerReadout(FxDrainState::Online, 3) != footerReadout(FxDrainState::Offline, 3)); +} + +static void testDrainReadoutsFitTheCountBox() { + // They borrow the count box, so neither may be long enough to make the kit's + // end-ellipsis the normal case. Twelve glyphs is the default 64px box's rough capacity + // at the panel's smallest font; a longer string wants a wider box, not a silent + // truncation. + CHECK(std::string(kFxParkingReadout).size() <= 12); + CHECK(std::string(kFxRestoringReadout).size() <= 12); + CHECK(!std::string(kFxParkingReadout).empty()); + CHECK(!std::string(kFxRestoringReadout).empty()); +} + int main() { testWideFooterAllPlaced(); testOffsetFooterAnchorsLeft(); @@ -277,6 +323,10 @@ int main() { testActiveSegmentStaysLiveWhileTheTransportRuns(); testInactiveSegmentGoesDeadWhenUnroutable(); testActiveSegmentStaysLiveEvenWhenUnroutable(); + testIdleReadsTheCountAndNeitherDrainString(); + testEachDirectionPreEmptsTheCount(); + testTheTwoDirectionsReadDifferently(); + testDrainReadoutsFitTheCountBox(); if (g_fail == 0) std::printf("footer_bar: all tests passed\n"); else std::printf("footer_bar: %d CHECK(s) FAILED\n", g_fail);