Merge the FX-drain busy indicator: a busy cursor and a direction-naming footer readout over fxReclaimActivity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
// (computeSegmentRects / hitTestSegment) for per-segment tiling — mode_switch stays the one
|
||||
// owner of segment geometry.
|
||||
|
||||
#include <string>
|
||||
|
||||
#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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HDROP>(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 {
|
||||
|
||||
@@ -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 <cstdio>
|
||||
#include <string>
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user