L4: re-home dock-panel buttons into three zones

Top toolbar = capture+placement, bottom toolbar = Design-View verbs, footer = narrow Arrange|Design toggle + count + Tail button + set-apart Prune. New pure footer_bar module; Tail is now a real kit button.
This commit is contained in:
2026-07-26 22:42:56 -04:00
parent 7dd9de14df
commit 8f4a84ac79
7 changed files with 817 additions and 278 deletions
+21 -1
View File
@@ -370,6 +370,21 @@ target_include_directories(component_geometry PUBLIC src)
add_library(action_bar STATIC src/action_bar.cpp) add_library(action_bar STATIC src/action_bar.cpp)
target_include_directories(action_bar PUBLIC src) target_include_directories(action_bar PUBLIC src)
# ---------------------------------------------------------------------------
# 2p) Pure footer_bar layout — NO REAPER, NO SWELL, NO LICE. The Phase L (L4)
# dock-panel button-layout enhancement's footer LEFT group: the narrowed
# [Arrange|Design] mode toggle, its compact per-mode count label, and the Tail
# button, laid out left-to-right with greedy right-reserve suppression (the RIGHT
# Prune button stays owned by prune_button). Point -> Toggle/Tail/None hit-test.
# Split out so the footer's new multi-affordance row math is unit-tested outside
# the DAW; the bank_panel L1-kit draw + tail-cycle/mode-activate dispatch are
# DAW-verified. Reuses prune_button's FooterRect input type. Mirror of action_bar /
# mode_switch / prune_button.
# ---------------------------------------------------------------------------
add_library(footer_bar STATIC src/footer_bar.cpp)
target_include_directories(footer_bar PUBLIC src)
target_link_libraries(footer_bar PUBLIC prune_button)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3) Standalone tests for the pure modules (run without launching REAPER). # 3) Standalone tests for the pure modules (run without launching REAPER).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -484,6 +499,10 @@ add_executable(action_bar_tests tests/test_action_bar.cpp)
target_link_libraries(action_bar_tests PRIVATE action_bar) target_link_libraries(action_bar_tests PRIVATE action_bar)
add_test(NAME action_bar_tests COMMAND action_bar_tests) add_test(NAME action_bar_tests COMMAND action_bar_tests)
add_executable(footer_bar_tests tests/test_footer_bar.cpp)
target_link_libraries(footer_bar_tests PRIVATE footer_bar)
add_test(NAME footer_bar_tests COMMAND footer_bar_tests)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -528,8 +547,9 @@ add_library(reaper_reasampler MODULE
src/owned_manifest.cpp src/owned_manifest.cpp
src/drag_out_win.cpp src/drag_out_win.cpp
src/action_bar.cpp src/action_bar.cpp
src/footer_bar.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 action_buttons drag_out theme component_geometry action_bar) 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 drag_out theme component_geometry action_bar footer_bar)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # 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' # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
+32 -16
View File
@@ -1,12 +1,18 @@
#pragma once #pragma once
// action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's // action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's
// TASK-GROUPED action bar (Phase L, L2). L2's dock-panel layout redesign (DS-3: a thorough // TASK-GROUPED toolbars (Phase L, L2 + L4). L2's dock-panel layout redesign (DS-3: a thorough
// layout, not a re-skin) groups the M11 action-trigger button inventory BY TASK — a compact // layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact bar of
// bar of clusters (capture / placement / maintenance) instead of one flat equal-tiled strip // clusters instead of one flat equal-tiled strip (the M11 action_buttons row this supersedes
// (the M11 action_buttons row this supersedes for the panel's action inventory). Each button // for the panel's action inventory). Each button carries a label sub-rect and a keybinding-help
// carries a label sub-rect and a keybinding-help MICRO sub-rect ("icon+label, keybinding as a // MICRO sub-rect ("icon+label, keybinding as a micro sub-label" — the L2 contract), and the bar
// micro sub-label" — the L2 contract), and the bar degrades gracefully on a narrow panel by // degrades gracefully on a narrow panel by dropping WHOLE trailing buttons (never clipping) so
// dropping WHOLE trailing buttons (never clipping) so the frequent capture cluster survives. // the frequent leading cluster survives.
//
// L4 re-homes the inventory across TWO toolbars, BOTH driven by this one module: a TOP toolbar
// (Capture + Placement — the two acts the tool exists for) and a BOTTOM toolbar (the Design-View
// verbs, Tagging then Switching). The tiling is cluster-agnostic — it walks the caller's
// ClusterSpec list in order — so the same computeBarSlots / hitTestActionBar serve both bars;
// only the cluster membership and the band rect differ per toolbar.
// //
// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): the panel shell owns the SWELL // Why pure (CLAUDE.md §load-bearing split, DS-1 caution): the panel shell owns the SWELL
// window, the L1-kit draws, and the NamedCommandLookup/Main_OnCommand dispatch — all // window, the L1-kit draws, and the NamedCommandLookup/Main_OnCommand dispatch — all
@@ -32,13 +38,22 @@
namespace reasampler { namespace reasampler {
// The task cluster a button belongs to (the L2 "group by task" mandate). Capture is the // The task cluster a button belongs to (the L2 "group by task" mandate). The order here is
// primary/frequent gesture (leftmost), then placement, then the rarer maintenance actions. // NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum
// The order here IS the left-to-right cluster order in the bar. // only names the groups so a slot can carry (and a test/shell can assert) its membership.
//
// L4 split the panel's buttons across TWO toolbars, each an action_bar instance:
// * the TOP toolbar draws Capture + Placement (the two acts the tool exists for);
// * the BOTTOM toolbar draws the Design-View verbs, grouped Tagging then Switching.
// Both toolbars share this ONE pure layout module (the tiling is cluster-agnostic — it walks
// the caller's ClusterSpec list in order), so a cluster value belongs to whichever toolbar
// the shell places it in; nothing here couples a cluster to a specific bar.
enum class ActionCluster { enum class ActionCluster {
Capture, // capture item / track / realtime / batch — the primary gesture Capture, // capture item / track / realtime / batch — top toolbar, primary gesture
Placement, // insert at cursor / insert-conform — placing a bank sample on the timeline Placement, // insert at cursor / insert-conform — top toolbar, placing a sample
Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions
Tagging, // tag / untag selected tracks for the active mode — bottom toolbar (L4)
Switching, // activate Arrange / Design, toggle mode, show-both — bottom toolbar (L4)
}; };
// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the // The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the
@@ -90,10 +105,11 @@ struct ActionBarSlot {
}; };
// One cluster's button count, in the caller's flat action-list order. The caller passes these // One cluster's button count, in the caller's flat action-list order. The caller passes these
// in ActionCluster order (Capture, Placement, Maintenance); a cluster with count 0 is skipped // in the left-to-right order it wants them drawn (top toolbar: Capture then Placement; bottom
// (no gap emitted for it). The flat action index a slot carries is the running sum across // toolbar: Tagging then Switching); a cluster with count 0 is skipped (no gap emitted for it).
// clusters (cluster 0's buttons are indices [0, counts[0]), etc.), so the shell's flat action // The flat action index a slot carries is the running sum across clusters (cluster 0's buttons
// table lines up with the slots by index. // are indices [0, counts[0]), etc.), so the shell's flat action table lines up with the slots
// by index.
struct ClusterSpec { struct ClusterSpec {
ActionCluster cluster = ActionCluster::Capture; ActionCluster cluster = ActionCluster::Capture;
int count = 0; int count = 0;
+309 -243
View File
@@ -58,6 +58,7 @@
#include "capture_paths.h" #include "capture_paths.h"
#include "component_geometry.h" // KitBox — the kit text()'s draw box (L1) #include "component_geometry.h" // KitBox — the kit text()'s draw box (L1)
#include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1) #include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1)
#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4)
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) #include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) #include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) #include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
@@ -143,25 +144,32 @@ const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10};
constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k)
// --- Mode-switch header (D5) -------------------------------------------------- // --- Footer (Phase L, L4) -----------------------------------------------------
constexpr int kHeaderHeight = 30; // The footer now carries a task-cluster of small persistent controls: the narrowed
// [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 —
// a real kit button, no longer a click-zone), and the set-apart Prune button at the
// right. Taller than the L2 footer to host the toggle segments + button chrome cleanly.
// Layout is the pure footer_bar (left group) + prune_button (right); this is the band height.
constexpr int kFooterHeight = 30;
// --- Tail-mode footer (T1 exposure) ------------------------------------------- // --- Toolbars (Phase L, L4) ---------------------------------------------------
constexpr int kFooterHeight = 26; // TWO task-grouped toolbars, both drawn through the pure action_bar module:
// * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of
// the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there.
// * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above
// the footer (L4 §2). This is the L2 action-bar band, repurposed.
// The kBarSpec metrics the bars consume live near the draw below; only heights live here.
constexpr int kTopToolbarHeight = 40; // taller — hosts the label + keybinding micro sub-row
constexpr int kBottomToolbarHeight = 40; // same shape (label + keybinding sub-row)
// --- Action bar (Phase L, L2) ------------------------------------------------- // --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) -------
// A fixed-height band of kit-drawn task-grouped buttons directly ABOVE the tail footer
// (below the split body). Layout/hit-test is the pure action_bar module; the metrics it
// consumes are kBarSpec (below, near the draw). Only the band height lives here.
constexpr int kButtonStripHeight = 28;
// --- Vertical split + region headers + tab strip (Phase B4) -------------------
// //
// The client area, top to bottom: mode-switch header (kHeaderHeight) | split body | // The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) |
// action bar (kButtonStripHeight) | tail footer (kFooterHeight). The split body holds the // split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer
// pool region (top) and the named-banks region (bottom). Each region opens with a REGION // (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool
// HEADER band: a title, the active-bank readout, and a full-height toggle button. The // region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER
// named-banks region's header ALSO hosts the LICE tab strip and a "+" create button. // band: a title, the active-bank readout, and a full-height toggle button. The named-banks
// region's header ALSO hosts the LICE tab strip and a "+" create button.
constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band
constexpr int kTabStripHeight = 26; // the named-banks tab strip band constexpr int kTabStripHeight = 26; // the named-banks tab strip band
constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions
@@ -198,14 +206,15 @@ enum class DropKind { None, PoolRegion, Tab, BanksRegion };
// `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A. // `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A.
enum class HoverKind { enum class HoverKind {
None, None,
ActionBarButton, // a button in the task-grouped action bar (index = flat action index) TopBarButton, // a button in the TOP toolbar (index = flat action index into topBarRows)
BottomBarButton, // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows)
PruneButton, PruneButton,
FullHtPool, // pool region full-height toggle FullHtPool, // pool region full-height toggle
FullHtBanks, // banks region full-height toggle FullHtBanks, // banks region full-height toggle
CreateBank, // the "+" create-bank button CreateBank, // the "+" create-bank button
Tab, // a named-bank tab (index = tab ordinal) Tab, // a named-bank tab (index = tab ordinal)
Footer, // the tail-mode toggle strip TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone)
ModeSegment, // a mode-switch segment (index = segment ordinal) ModeSegment, // a footer mode-toggle segment (index = segment ordinal)
}; };
struct Hover { struct Hover {
@@ -506,52 +515,27 @@ void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt,
text(bmp, box, txt, font, role, align); text(bmp, box, txt, font, role, align);
} }
// --- Mode-switch header (D5, unchanged) --------------------------------------- // --- Mode toggle (D5; relocated to the footer at L4) --------------------------
HeaderRect panelHeader(int w) { return HeaderRect{0, 0, w, kHeaderHeight}; }
int modeCount() { int modeCount() {
if (!g_panel.session) return 0; if (!g_panel.session) return 0;
return static_cast<int>(g_panel.session->view().modes().size()); return static_cast<int>(g_panel.session->view().modes().size());
} }
void drawModeSwitch(LICE_IBitmap* bmp, int w) { // --- Top toolbar band (L4) ----------------------------------------------------
if (!g_panel.session) return; //
const ViewModeModel& view = g_panel.session->view(); // The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate
const std::vector<Mode>& modes = view.modes().all(); // (height 0) when the client is too short to host it above the split body.
const int n = static_cast<int>(modes.size()); ActionBarRect topToolbarRect(int w) {
ActionBarRect s;
// Header band — the base canvas (L2: kit bg/base surface). s.x = 0;
fillSurface(bmp, KitBox{0, 0, w, kHeaderHeight}, Role::BgBase, InteractionState::Rest); s.y = 0;
if (n <= 0) return; s.width = w;
s.height = kTopToolbarHeight;
const HeaderRect header = panelHeader(w); return s;
const std::vector<SegmentRect> segs = computeSegmentRects(header, n);
if (segs.empty()) return;
const std::string& activeId = view.activeModeId();
for (int i = 0; i < n; ++i) {
const SegmentRect& s = segs[static_cast<std::size_t>(i)];
const Mode& mode = modes[static_cast<std::size_t>(i)];
const bool active = mode.id == activeId;
// Active segment carries the accent (Active); else hover-or-rest bg/cell. Text goes
// bg/base on the accent fill for contrast, else text/primary (the kit's convention).
const InteractionState state =
active ? InteractionState::Active
: hoverState(g_panel.hovered, HoverKind::ModeSegment, i);
fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state);
LICE_DrawRect(bmp, s.x, s.y, s.width, s.height,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
const Role tr = active ? Role::BgBase : Role::TextPrimary;
kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(),
Font::Label, tr, Align::Center);
}
} }
// --- Tail-mode footer (T1, unchanged) ----------------------------------------- // --- Footer (L4) --------------------------------------------------------------
RECT panelFooter(int w, int h) { RECT panelFooter(int w, int h) {
RECT rc{}; RECT rc{};
@@ -559,7 +543,8 @@ RECT panelFooter(int w, int h) {
rc.right = w; rc.right = w;
rc.top = h - kFooterHeight; rc.top = h - kFooterHeight;
rc.bottom = h; rc.bottom = h;
if (rc.top < kHeaderHeight) rc.top = rc.bottom; // Keep the footer below the top toolbar; if the client is too short, collapse it.
if (rc.top < kTopToolbarHeight) rc.top = rc.bottom;
return rc; return rc;
} }
@@ -569,47 +554,36 @@ TailSetting currentTail() {
return g_panel.session ? g_panel.session->tail() : TailSetting{}; return g_panel.session ? g_panel.session->tail() : TailSetting{};
} }
// Draws the tail-mode toggle into the footer strip: a filled band, a top divider, // The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client
// and the current mode's label ("Tail: Off / Auto / Manual Xs") from the pure // size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate.
// tail_control module. READ-ONLY: reads session->tail(); the input handlers mutate it. FooterBarLayout footerBarLayoutFor(int w, int h) {
void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
const RECT f = panelFooter(w, h); const RECT f = panelFooter(w, h);
if (f.top >= f.bottom) return; if (f.top >= f.bottom) return FooterBarLayout{};
const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top};
return computeFooterBar(footer, FooterBarSpec{});
}
// Footer band (L2 kit surface). Hover lightens the whole strip since a footer click // The per-mode membership count that travels with the toggle (L4 §3): the number of leaves
// cycles the tail mode (the strip IS the toggle control). // tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no
const InteractionState footerState = // session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.)
hoverState(g_panel.hovered, HoverKind::Footer, -1); // A display-only tally over the model's public membership map — no model semantics duplicated.
fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel, footerState); int activeModeMemberCount() {
LICE_Line(bmp, f.left, f.top, f.right, f.top, if (!g_panel.session) return 0;
toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); const ViewModeModel& view = g_panel.session->view();
const std::string& active = view.activeModeId();
// Tail-mode toggle, left-aligned (the interactive control — footer clicks cycle it). if (active.empty()) return 0;
const std::string label = tailToggleLabel(currentTail()); int n = 0;
kitText(bmp, KitBox{f.left + 8, f.top, (f.right - f.left) - 8, f.bottom - f.top}, for (const auto& [guid, m] : view.membership().all())
label.c_str(), Font::Label, Role::TextPrimary, Align::Left); if (m.modeIds.count(active) != 0) ++n;
return n;
// Version/channel readout (Phase V, V3/V4), right-aligned in the same footer strip so
// it is always visible but unobtrusive. appVersion() renders "0.9.01" on stable and
// "0.9.01-beta" on beta, so a beta panel self-identifies its channel here. Right inset
// matches the left inset; DT_RIGHT keeps it clear of the left-aligned tail label
// (the two never overlap at normal panel widths — the label is short, the readout is
// ~10 chars, and DT_END_ELLIPSIS on both degrades gracefully if a panel is ever tiny).
// COUPLED TO PruneButtonSpec::rightInset (prune_button.h): the prune button is
// right-anchored at footer.right - 84, placing its right edge 76 px left of this
// readout's right margin. If this inset (currently 8) changes, update rightInset there.
// Version/channel readout — dim (text/dim), passive identification (V3 unobtrusive).
kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top},
reasampler::appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right);
} }
// The prune button's rect within the footer, derived from the client size. SINGLE source // The prune button's rect within the footer, derived from the client size. SINGLE source
// of truth for both draw and hit-test (they never drift). Empty (button.empty()) when the // of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate
// footer is degenerate or too narrow to place the button clear of the tail label — the // or too narrow to place the button clear of the footer-left group / version readout — the
// action stays reachable via its bindable command, so a suppressed button is graceful. // action stays reachable via its bindable command, so a suppressed button is graceful. Kept
// Clearance from the version readout: PruneButtonSpec::rightInset (84) places the button // set apart at the RIGHT (footer_bar reserves the matching space at its right so the two
// right edge 76 px left of the readout's 8 px right margin — see coupling comments in // groups never overlap). See prune_button.h §Placement contract.
// prune_button.h and drawTailFooter above.
ButtonRect pruneButtonRectFor(int w, int h) { ButtonRect pruneButtonRectFor(int w, int h) {
const RECT f = panelFooter(w, h); const RECT f = panelFooter(w, h);
if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button
@@ -617,22 +591,88 @@ ButtonRect pruneButtonRectFor(int w, int h) {
return computePruneButton(footer, PruneButtonSpec{}); return computePruneButton(footer, PruneButtonSpec{});
} }
// Draws the prune button into the footer (called after drawTailFooter fills the strip). // Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design]
// No-op when the button is suppressed (footer too narrow). READ-ONLY: draws only. // toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and
void drawPruneButton(LICE_IBitmap* bmp, int w, int h) { // the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button
const ButtonRect b = pruneButtonRectFor(w, h); // set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it.
if (b.empty()) return; void drawFooter(LICE_IBitmap* bmp, int w, int h) {
const RECT f = panelFooter(w, h);
if (f.top >= f.bottom) return;
// The ONLY warn-colored control (byte-deleting): kit drawButton with warn=true, set apart // Footer band + hairline top divider (the base persistent-controls strip).
// in the footer, honoring hover. Its label draws inside the button (kit centers it). fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel,
const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1); InteractionState::Rest);
const KitButtonBox box{KitBox{b.x, b.y, b.width, b.height}}; LICE_Line(bmp, f.left, f.top, f.right, f.top,
drawButton(bmp, box, "Prune", state, /*warn=*/true); toLice(roleColor(Role::LineHairline)), 1.0f, 0, false);
const FooterBarLayout fb = footerBarLayoutFor(w, h);
// [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box
// (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The
// active mode's segment carries the accent; others hover-or-rest bg/cell.
if (!fb.toggle.empty() && g_panel.session) {
const ViewModeModel& view = g_panel.session->view();
const std::vector<Mode>& modes = view.modes().all();
const int n = static_cast<int>(modes.size());
const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height};
const std::vector<SegmentRect> segs = computeSegmentRects(th, n);
const std::string& activeId = view.activeModeId();
for (int i = 0; i < static_cast<int>(segs.size()); ++i) {
const SegmentRect& s = segs[static_cast<std::size_t>(i)];
const Mode& mode = modes[static_cast<std::size_t>(i)];
const bool active = mode.id == activeId;
const InteractionState state =
active ? InteractionState::Active
: hoverState(g_panel.hovered, HoverKind::ModeSegment, i);
fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state);
LICE_DrawRect(bmp, s.x, s.y, s.width, s.height,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
const Role tr = active ? Role::BgBase : Role::TextPrimary;
kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(),
Font::Label, tr, Align::Center);
}
}
// Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count
// travels with the toggle"). Passive text, not a control.
if (!fb.count.empty()) {
const int members = activeModeMemberCount();
const std::string countLabel =
std::to_string(members) + (members == 1 ? " track" : " tracks");
kitText(bmp, KitBox{fb.count.x, fb.count.y, fb.count.width, fb.count.height},
countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center);
}
// Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the
// tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel.
if (!fb.tail.empty()) {
const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1);
const std::string label = tailToggleLabel(currentTail());
const KitButtonBox box{KitBox{fb.tail.x, fb.tail.y, fb.tail.width, fb.tail.height}};
drawButton(bmp, box, label.c_str(), state, /*warn=*/false);
}
// Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion()
// renders "0.9.01" on stable and "0.9.01-beta" on beta so a beta panel self-identifies.
// It sits inside the space footer_bar reserves at the right (rightReserve) and clears the
// prune button (prune_button::rightInset). Dim, passive identification (V3).
kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top},
reasampler::appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right);
// Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control),
// honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign,
// frequent) -> right (destructive, rare) per the L4 footer contract.
const ButtonRect pb = pruneButtonRectFor(w, h);
if (!pb.empty()) {
const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1);
const KitButtonBox box{KitBox{pb.x, pb.y, pb.width, pb.height}};
drawButton(bmp, box, "Prune", state, /*warn=*/true);
}
} }
// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. // True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the
// Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust) // scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The
// so both agree on the hit target. // Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar).
bool pointInFooter(int x, int y) { bool pointInFooter(int x, int y) {
if (!g_panel.hwnd) return false; if (!g_panel.hwnd) return false;
RECT cr{}; RECT cr{};
@@ -654,46 +694,41 @@ void markTailDirty() {
if (proj) MarkProjectDirty(proj); if (proj) MarkProjectDirty(proj);
} }
// === Task-grouped action bar (Phase L, L2) ==================================== // === Task-grouped toolbars (Phase L, L2 + L4) =================================
// //
// The M11 flat equal-tiled action strip is redesigned into a TASK-GROUPED bar (DS-3): a // L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin)
// compact toolbar of clusters — Capture (the primary gesture), Placement, Maintenance — // across TWO toolbars, BOTH drawn through the pure action_bar module:
// each button drawn through the L1 kit's drawButton with the action name (Font::Label) and // * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the
// its live key binding on a Micro sub-row ("icon+label, keybinding as a micro sub-label" — // two acts the tool exists for (L4 §1);
// the L2 contract). The pure action_bar module owns the cluster tiling, the label/binding // * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the
// sub-rects, the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK // footer, in the space capture/placement vacated (L4 §2).
// binding query + the NamedCommandLookup/Main_OnCommand dispatch live here (unchanged from // Each button is drawn with its action name (Font::Label) and live key binding on a Micro
// M11 — L2 re-places and re-draws, it does not re-wire behavior). // sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects,
// the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query
// + the NamedCommandLookup/Main_OnCommand dispatch live here.
// //
// Each button still resolves its command id at RUNTIME from the composed named-command // Each button resolves its command id at RUNTIME from the composed named-command string
// string (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct // (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable
// on stable and beta and adds NO second registration. A cmd of 0 (action not registered on // and beta and adds NO second registration. A cmd of 0 (action not registered on this channel)
// this channel) draws Disabled and no-ops on click. // draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the
// SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts.
// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel // One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix
// prefix at fire time — never a hardcoded numeric id), its terse on-button label, and the // at fire time — never a hardcoded numeric id), its terse on-button label, and the task cluster
// task cluster it belongs to. The order of this list IS the flat action index the pure // it belongs to. The order of a toolbar's row list IS the flat action index the pure action_bar
// action_bar slots carry, so the list must be built cluster-by-cluster in ActionCluster // slots carry, so each list is built cluster-by-cluster in its toolbar's cluster order.
// order (Capture, then Placement, then Maintenance).
struct ActionBarRow { struct ActionBarRow {
std::string suffix; std::string suffix;
std::string shortLabel; std::string shortLabel;
ActionCluster cluster = ActionCluster::Capture; ActionCluster cluster = ActionCluster::Capture;
}; };
// The full action inventory, grouped by task and TABLE-DRIVEN where possible: the capture // The TOP toolbar inventory: Capture (item / track / batch items / batch razor / RT) then
// scopes come from captureActionTable() (render_settings, pure), then batch capture and // Placement (insert / insert-conform). Capture scopes come from captureActionTable()
// realtime capture round out the Capture cluster; the two insert variants form Placement; // (render_settings, pure); the rest are the registered M11/M6 commands. RECONCILED against the
// re-capture + cancel-realtime form Maintenance. Built once per draw/click — cheap (a // actually-REGISTERED commands — "resample-and-mute" / "null-test verify" are not registered
// handful of small strings) and always in step with the registered families. // commands and drag-out is a mouse gesture, so none are placed. Built once per draw/click.
// std::vector<ActionBarRow> topBarRows() {
// RECONCILED against the actually-REGISTERED commands (main.cpp / render_settings): the
// contract's forecast list named "resample-and-mute-source" and "null-test verify" buttons,
// which are NOT registered as commands on this branch, and "drag-out", which is a mouse
// gesture (drag a selection out of the panel) not a bindable action — none are placed as
// buttons. What IS placed is every registered non-destructive action. Prune (the only
// byte-deleting verb) stays set-apart in the footer, warn-marked (prune_button).
std::vector<ActionBarRow> actionBarRows() {
std::vector<ActionBarRow> rows; std::vector<ActionBarRow> rows;
// Capture cluster — the primary gesture, leftmost. // Capture cluster — the primary gesture, leftmost.
for (const CaptureActionDef& def : captureActionTable()) { for (const CaptureActionDef& def : captureActionTable()) {
@@ -705,49 +740,75 @@ std::vector<ActionBarRow> actionBarRows() {
rows.push_back({"CAPTURE_BATCH_ITEMS", "Batch Items", ActionCluster::Capture}); rows.push_back({"CAPTURE_BATCH_ITEMS", "Batch Items", ActionCluster::Capture});
rows.push_back({"CAPTURE_BATCH_RAZOR", "Batch Razor", ActionCluster::Capture}); rows.push_back({"CAPTURE_BATCH_RAZOR", "Batch Razor", ActionCluster::Capture});
rows.push_back({"CAPTURE_TRACK_REALTIME", "Capture RT", ActionCluster::Capture}); rows.push_back({"CAPTURE_TRACK_REALTIME", "Capture RT", ActionCluster::Capture});
// Placement cluster. // Placement cluster — the second act (still a distinct on-demand act; no auto-insert).
rows.push_back({"INSERT_SELECTED", "Insert", ActionCluster::Placement}); rows.push_back({"INSERT_SELECTED", "Insert", ActionCluster::Placement});
rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", ActionCluster::Placement}); rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", ActionCluster::Placement});
// Maintenance cluster — rarer upkeep.
rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", ActionCluster::Maintenance});
rows.push_back({"CANCEL_REALTIME_CAPTURE", "Cancel RT", ActionCluster::Maintenance});
return rows; return rows;
} }
// The cluster button-count specs for a given row set, in ActionCluster order (the order the // The BOTTOM toolbar inventory: the Design-View action family, grouped Tagging then Switching
// rows were built in), so the pure action_bar's flat index lines up with actionBarRows(). // (L4 §2). The suffixes are the ACTUAL registered command-id strings from actions.cpp
// (VIEW_TAG_DESIGN / VIEW_UNTAG / VIEW_ACTIVATE_ARRANGE / VIEW_ACTIVATE_DESIGN /
// VIEW_TOGGLE_MODE / VIEW_SHOW_BOTH) — grepped, not paraphrased. "Tag Design" tags the
// selection into the Design mode; "Untag" returns the selection to the Arrange default (the
// shared body behind both untag and tag->Arrange). Firing routes through the SAME command-id
// contract the keybindings use — L4 gives these registered actions a button home, unchanged.
std::vector<ActionBarRow> bottomBarRows() {
std::vector<ActionBarRow> rows;
// Tagging cluster.
rows.push_back({"VIEW_TAG_DESIGN", "Tag Design", ActionCluster::Tagging});
rows.push_back({"VIEW_UNTAG", "Untag", ActionCluster::Tagging});
// Switching cluster.
rows.push_back({"VIEW_ACTIVATE_ARRANGE", "Arrange", ActionCluster::Switching});
rows.push_back({"VIEW_ACTIVATE_DESIGN", "Design", ActionCluster::Switching});
rows.push_back({"VIEW_TOGGLE_MODE", "Toggle", ActionCluster::Switching});
rows.push_back({"VIEW_SHOW_BOTH", "Show Both", ActionCluster::Switching});
return rows;
}
// The cluster button-count specs for a given row set, in the row list's cluster order (so the
// pure action_bar's flat index lines up with the row list). Handles all five cluster kinds;
// empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec
// order follows each toolbar's fixed layout order (top: Capture, Placement; bottom: Tagging,
// Switching); Maintenance is retained for completeness though no current toolbar uses it.
std::vector<ClusterSpec> actionBarClusters(const std::vector<ActionBarRow>& rows) { std::vector<ClusterSpec> actionBarClusters(const std::vector<ActionBarRow>& rows) {
int nCap = 0, nPlace = 0, nMaint = 0; int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0;
for (const ActionBarRow& r : rows) { for (const ActionBarRow& r : rows) {
if (r.cluster == ActionCluster::Capture) ++nCap; switch (r.cluster) {
else if (r.cluster == ActionCluster::Placement) ++nPlace; case ActionCluster::Capture: ++nCap; break;
else ++nMaint; case ActionCluster::Placement: ++nPlace; break;
case ActionCluster::Maintenance: ++nMaint; break;
case ActionCluster::Tagging: ++nTag; break;
case ActionCluster::Switching: ++nSwitch; break;
}
} }
return { return {
{ActionCluster::Capture, nCap}, {ActionCluster::Capture, nCap},
{ActionCluster::Placement, nPlace}, {ActionCluster::Placement, nPlace},
{ActionCluster::Maintenance, nMaint}, {ActionCluster::Maintenance, nMaint},
{ActionCluster::Tagging, nTag},
{ActionCluster::Switching, nSwitch},
}; };
} }
// The action-bar layout spec (the panel's 8px-grid density decision). One source of truth // The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared
// shared by draw and hit-test. // by both toolbars' draw and hit-test (identical button shape top and bottom).
const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/16, const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/16,
/*sidePad=*/8, /*verticalInset=*/3, /*bindingHeight=*/11, /*sidePad=*/8, /*verticalInset=*/3, /*bindingHeight=*/11,
/*minSplitHeight=*/30}; /*minSplitHeight=*/30};
// The action-bar band: a fixed-height band directly above the tail footer (below the split // The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split
// body). Degenerate (height 0) when the client is too short to host it above the footer. // body). Degenerate (height 0) when the client is too short to host it above the footer.
ActionBarRect actionBarRect(int w, int h) { ActionBarRect bottomToolbarRect(int w, int h) {
ActionBarRect s; ActionBarRect s;
const RECT footer = panelFooter(w, h); const RECT footer = panelFooter(w, h);
const int footerTop = (footer.top < footer.bottom) ? footer.top : h; const int footerTop = (footer.top < footer.bottom) ? footer.top : h;
s.x = 0; s.x = 0;
s.width = w; s.width = w;
s.height = kButtonStripHeight; s.height = kBottomToolbarHeight;
s.y = footerTop - kButtonStripHeight; s.y = footerTop - kBottomToolbarHeight;
// Keep the bar below the mode-switch header; if the client is too short, collapse it. // Keep the bar below the top toolbar; if the client is too short, collapse it.
if (s.y < kHeaderHeight) { s.y = footerTop; s.height = 0; } if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; }
return s; return s;
} }
@@ -769,21 +830,23 @@ std::string barBindingText(int cmd) {
return {}; return {};
} }
// Draws the task-grouped action bar through the L1 kit: a bg/panel band, then each visible // Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button
// button as a kit drawButton (rest/hover/disabled) with the action NAME on the label row and // as a kit drawButton (rest/hover/disabled) with the action NAME on the label row and the key
// the key binding (or "unbound") on the Micro sub-row. Overflow drops WHOLE trailing buttons // binding (or "unbound") on the Micro sub-row. Overflow drops WHOLE trailing buttons (the pure
// (the pure layout returns only the buttons that fit), so nothing is drawn clipped. // layout returns only the buttons that fit), so nothing is drawn clipped. `hoverKind` selects
void drawActionBar(LICE_IBitmap* bmp, int w, int h) { // which HoverKind this bar's buttons use (TopBarButton / BottomBarButton) so the two toolbars'
const ActionBarRect bar = actionBarRect(w, h); // hover states never cross. `topDivider` draws a hairline at the band's top edge (the bottom
// toolbar's elevation over the split body); the top toolbar draws it at its bottom edge instead.
void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar,
const std::vector<ActionBarRow>& rows, HoverKind hoverKind, bool topDivider) {
if (bar.height <= 0 || bar.width <= 0) return; if (bar.height <= 0 || bar.width <= 0) return;
// Band surface + a hairline top divider (elevation over the split body).
const KitBox band{bar.x, bar.y, bar.width, bar.height}; const KitBox band{bar.x, bar.y, bar.width, bar.height};
fillSurface(bmp, band, Role::BgPanel, InteractionState::Rest); fillSurface(bmp, band, Role::BgPanel, InteractionState::Rest);
LICE_Line(bmp, bar.x, bar.y, bar.x + bar.width, bar.y, const int dividerY = topDivider ? bar.y : bar.y + bar.height - 1;
LICE_Line(bmp, bar.x, dividerY, bar.x + bar.width, dividerY,
toLice(roleColor(Role::LineHairline)), 0.5f, 0, false); toLice(roleColor(Role::LineHairline)), 0.5f, 0, false);
const std::vector<ActionBarRow> rows = actionBarRows();
const std::vector<ClusterSpec> clusters = actionBarClusters(rows); const std::vector<ClusterSpec> clusters = actionBarClusters(rows);
const std::vector<ActionBarSlot> slots = computeBarSlots(bar, clusters, kBarSpec); const std::vector<ActionBarSlot> slots = computeBarSlots(bar, clusters, kBarSpec);
@@ -796,8 +859,7 @@ void drawActionBar(LICE_IBitmap* bmp, int w, int h) {
// hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.) // hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.)
InteractionState state = InteractionState::Rest; InteractionState state = InteractionState::Rest;
if (cmd == 0) state = InteractionState::Disabled; if (cmd == 0) state = InteractionState::Disabled;
else if (g_panel.hovered.kind == HoverKind::ActionBarButton && else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index)
g_panel.hovered.index == s.index)
state = InteractionState::Hover; state = InteractionState::Hover;
// The button surface (drawButton draws the micro-gradient + rounded border + honors // The button surface (drawButton draws the micro-gradient + rounded border + honors
@@ -818,8 +880,6 @@ void drawActionBar(LICE_IBitmap* bmp, int w, int h) {
// alone and strip the leading name-less case. // alone and strip the leading name-less case.
const std::string binding = barBindingText(cmd); const std::string binding = barBindingText(cmd);
const std::string sub = formatButtonLabel("", binding); // "" + " (unbound)" / " <bind>" const std::string sub = formatButtonLabel("", binding); // "" + " (unbound)" / " <bind>"
// formatButtonLabel prefixes with the name; with an empty name it yields
// " (unbound)" or " <binding>" — trim the leading spaces for the sub-row.
std::size_t start = sub.find_first_not_of(' '); std::size_t start = sub.find_first_not_of(' ');
const std::string shown = (start == std::string::npos) ? sub : sub.substr(start); const std::string shown = (start == std::string::npos) ? sub : sub.substr(start);
const KitBox bindBox{s.bindX, s.bindY, s.bindW, s.bindH}; const KitBox bindBox{s.bindX, s.bindY, s.bindW, s.bindH};
@@ -828,38 +888,26 @@ void drawActionBar(LICE_IBitmap* bmp, int w, int h) {
} }
} }
// The flat action index under (x, y) in the action bar, or -1 (miss). Pure hit-test. // The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test.
int actionBarHit(int x, int y) { int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector<ActionBarRow>& rows) {
if (!g_panel.hwnd) return -1;
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
const ActionBarRect bar = actionBarRect(w, h);
if (bar.height <= 0) return -1; if (bar.height <= 0) return -1;
const std::vector<ActionBarRow> rows = actionBarRows();
return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec); return hitTestActionBar(x, y, bar, actionBarClusters(rows), kBarSpec);
} }
// Routes a click in the action bar to the hit button's action, fired through the command-id // Routes a click in a toolbar to the hit button's action, fired through the command-id contract
// contract (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true // (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click
// iff the click was inside the bar band (handled, or a harmless gap/overflow/unregistered // was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the
// no-op), so the caller stops before grid handling. // caller stops before grid handling. `rows` is the toolbar's inventory.
bool handleActionBarClick(int x, int y) { bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
if (!g_panel.hwnd) return false; const std::vector<ActionBarRow>& rows) {
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
const ActionBarRect bar = actionBarRect(w, h);
if (bar.height <= 0) return false; if (bar.height <= 0) return false;
const int hit = toolbarHit(x, y, bar, rows);
const int hit = actionBarHit(x, y);
if (hit < 0) { if (hit < 0) {
// Inside the band but in a gap / overflow dead-zone: claim it so it never falls // Inside the band but in a gap / overflow dead-zone: claim it so it never falls through
// through to the grid. Outside the band: not ours. // to the grid. Outside the band: not ours.
return y >= bar.y && y < bar.y + bar.height && return y >= bar.y && y < bar.y + bar.height &&
x >= bar.x && x < bar.x + bar.width; x >= bar.x && x < bar.x + bar.width;
} }
const std::vector<ActionBarRow> rows = actionBarRows();
const int cmd = resolveBarCommandId(rows[static_cast<std::size_t>(hit)]); const int cmd = resolveBarCommandId(rows[static_cast<std::size_t>(hit)]);
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
return true; return true;
@@ -870,16 +918,15 @@ bool handleActionBarClick(int x, int y) {
// Every rect below is derived from the client size + fullHeight state, and BOTH paint // Every rect below is derived from the client size + fullHeight state, and BOTH paint
// and hit-testing call these so they never drift. All are top-left origin. // and hit-testing call these so they never drift. All are top-left origin.
// The body band between the mode-switch header and the tail footer. // The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the
// top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a
// short client, bottomToolbarRect returns its y at the footer top, so the body still ends there.
RECT splitBody(int w, int h) { RECT splitBody(int w, int h) {
RECT rc{}; RECT rc{};
rc.left = 0; rc.left = 0;
rc.right = w; rc.right = w;
rc.top = kHeaderHeight; rc.top = kTopToolbarHeight;
// The body ends at the action bar (L2), which itself sits above the tail footer. When const ActionBarRect bar = bottomToolbarRect(w, h);
// the bar collapses on a short client, actionBarRect returns its y at the footer top,
// so the body still ends at the footer edge.
const ActionBarRect bar = actionBarRect(w, h);
rc.bottom = bar.y; rc.bottom = bar.y;
if (rc.bottom < rc.top) rc.bottom = rc.top; if (rc.bottom < rc.top) rc.bottom = rc.top;
return rc; return rc;
@@ -1207,10 +1254,14 @@ void paintPanel(HWND hwnd, HDC hdc) {
} }
} }
drawModeSwitch(&bmp, w); // L4 three-zone chrome: TOP toolbar (capture + placement), BOTTOM toolbar (Design-View
drawActionBar(&bmp, w, h); // L2 task-grouped action bar, above the footer // verbs), then the footer (mode toggle + count + Tail button + Prune). Drawn last so they
drawTailFooter(&bmp, w, h); // sit over the split body's edges.
drawPruneButton(&bmp, w, h); // R3: raised over the footer strip drawToolbar(&bmp, topToolbarRect(w), topBarRows(), HoverKind::TopBarButton,
/*topDivider=*/false);
drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton,
/*topDivider=*/true);
drawFooter(&bmp, w, h);
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
} }
@@ -1912,16 +1963,35 @@ bool handlePoolChromeClick(int x, int y, const RECT& region) {
return false; return false;
} }
// Applies a left-click at (x, y): route to mode switch / footer / region chrome / // The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by
// grid selection, and arm a potential drag when the click lands on a selected cell. // mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box.
int footerToggleSegmentHit(int x, int y, int w, int h) {
if (!g_panel.session) return -1;
const FooterBarLayout fb = footerBarLayoutFor(w, h);
if (fb.toggle.empty()) return -1;
const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height};
return hitTestSegment(x, y, th, modeCount());
}
// Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) /
// bottom toolbar / region chrome / grid selection, and arm a potential drag when the click
// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom.
void handleClick(int x, int y) { void handleClick(int x, int y) {
RECT cr{}; RECT cr{};
GetClientRect(g_panel.hwnd, &cr); GetClientRect(g_panel.hwnd, &cr);
const int w = cr.right - cr.left, h = cr.bottom - cr.top; const int w = cr.right - cr.left, h = cr.bottom - cr.top;
// Mode-switch header (D5) takes precedence. // TOP toolbar (capture + placement) takes precedence — a button fires its registered
if (g_panel.session) { // action via the command-id contract; the band is claimed whole (a gap/overflow miss is a
const int seg = hitTestSegment(x, y, panelHeader(w), modeCount()); // harmless no-op, never a fall-through). Capture never auto-inserts (unchanged actions).
if (handleToolbarClick(x, y, topToolbarRect(w), topBarRows())) return;
// Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design]
// toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a
// click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar /
// grid so a footer click never selects a cell.
{
const int seg = footerToggleSegmentHit(x, y, w, h);
if (seg >= 0) { if (seg >= 0) {
const std::vector<Mode>& modes = g_panel.session->view().modes().all(); const std::vector<Mode>& modes = g_panel.session->view().modes().all();
if (seg < static_cast<int>(modes.size())) { if (seg < static_cast<int>(modes.size())) {
@@ -1931,14 +2001,23 @@ void handleClick(int x, int y) {
} }
return; return;
} }
}
// Prune button (R3): checked BEFORE the footer's tail-cycle so a click on the button const FooterBarLayout fb = footerBarLayoutFor(w, h);
// fires prune, not a tail cycle. Fires the "Prune bank folder" action THROUGH its if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) {
// registered command id (fork R-E: dispatch the command, do not call the session // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates
// directly), so the panel affordance and the bindable action share the one guarded // the SESSION's tail setting (capture reads it; persist saves it with the project)
// dry-run/confirm/delete path in doBankPruneFolder. A 0 id (pre-registration) no-ops. // and marks the project dirty — touches NOTHING in the bank/arrange.
{ TailSetting& tail = g_panel.session->tail();
tail.mode = cycleTailMode(tail.mode);
markTailDirty();
invalidatePanel();
return;
}
// Prune button (R3): fires the "Prune bank folder" action THROUGH its registered
// command id (fork R-E: dispatch the command, not the session directly) so the panel
// affordance and the bindable action share the one guarded dry-run/confirm/delete path
// in doBankPruneFolder. A 0 id (pre-registration) no-ops.
const ButtonRect pb = pruneButtonRectFor(w, h); const ButtonRect pb = pruneButtonRectFor(w, h);
if (hitTestPruneButton(x, y, pb)) { if (hitTestPruneButton(x, y, pb)) {
const int cmd = bankPruneCommandId(); const int cmd = bankPruneCommandId();
@@ -1947,24 +2026,9 @@ void handleClick(int x, int y) {
} }
} }
// Tail footer: a click anywhere in the bottom strip cycles the tail mode // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the
// (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail // command-id contract. Claimed whole like the top toolbar.
// setting (which the capture actions read and persist saves with the project) and if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return;
// marks the project dirty so the choice travels inside the .rpp — it touches
// NOTHING in the bank/arrange. Checked before the grid so a footer click never selects.
if (g_panel.session && pointInFooter(x, y)) {
TailSetting& tail = g_panel.session->tail();
tail.mode = cycleTailMode(tail.mode);
markTailDirty();
invalidatePanel();
return;
}
// Action bar (L2): a click on a button fires the registered action via the command-id
// contract. Checked before the region chrome / grid so a bar click never selects a cell;
// the handler claims the whole bar band (a miss on a gap / overflow dead-zone is a
// harmless no-op, not a fall-through to the grid below).
if (handleActionBarClick(x, y)) return;
// Region chrome (headers, tab strip, buttons). // Region chrome (headers, tab strip, buttons).
if (poolShown()) { if (poolShown()) {
@@ -2210,22 +2274,24 @@ Hover resolveHover(int x, int y) {
GetClientRect(g_panel.hwnd, &cr); GetClientRect(g_panel.hwnd, &cr);
const int w = cr.right - cr.left, h = cr.bottom - cr.top; const int w = cr.right - cr.left, h = cr.bottom - cr.top;
// Mode-switch header segments. // TOP toolbar buttons (matching the click order — first zone top-to-bottom).
if (g_panel.session) {
const int seg = hitTestSegment(x, y, panelHeader(w), modeCount());
if (seg >= 0) return Hover{HoverKind::ModeSegment, seg};
}
// Prune button (before the footer, matching the click order).
{ {
const int hit = toolbarHit(x, y, topToolbarRect(w), topBarRows());
if (hit >= 0) return Hover{HoverKind::TopBarButton, hit};
}
// Footer: mode-toggle segments, Tail button, then Prune (matching the click order).
{
const int seg = footerToggleSegmentHit(x, y, w, h);
if (seg >= 0) return Hover{HoverKind::ModeSegment, seg};
const FooterBarLayout fb = footerBarLayoutFor(w, h);
if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1};
const ButtonRect pb = pruneButtonRectFor(w, h); const ButtonRect pb = pruneButtonRectFor(w, h);
if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1};
} }
// Tail footer strip. // BOTTOM toolbar buttons.
if (pointInFooter(x, y)) return Hover{HoverKind::Footer, -1};
// Action bar.
{ {
const int hit = actionBarHit(x, y); const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows());
if (hit >= 0) return Hover{HoverKind::ActionBarButton, hit}; if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit};
} }
// Region chrome: full-height toggles, create button, tabs. // Region chrome: full-height toggles, create button, tabs.
if (poolShown()) { if (poolShown()) {
+69
View File
@@ -0,0 +1,69 @@
// footer_bar — pure implementation. See footer_bar.h. NO REAPER / SWELL / LICE / vendor.
#include "footer_bar.h"
namespace reasampler {
namespace {
// True iff a box [x, x+width) fits entirely left of `rightBound` (its right edge does not
// cross the reserved right region). A non-positive width never "fits" (nothing to place).
bool fitsLeftOf(int x, int width, int rightBound) {
return width > 0 && x + width <= rightBound;
}
bool pointIn(int px, int py, const FooterBarRect& r) {
return !r.empty() && px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height;
}
} // namespace
FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec) {
FooterBarLayout out;
if (footer.width <= 0 || footer.height <= 0) return out;
const int top = footer.y + spec.verticalInset;
const int boxH = footer.height - 2 * spec.verticalInset;
if (boxH <= 0) return out;
// The right bound the LEFT group must stay clear of (prune + version region). Clamp so a
// pathologically large rightReserve never yields a negative bound.
int rightBound = footer.x + footer.width - spec.rightReserve;
if (rightBound < footer.x) rightBound = footer.x;
int cursorX = footer.x + spec.leftPad;
// Toggle (most important — placed first, drops last).
if (fitsLeftOf(cursorX, spec.toggleWidth, rightBound)) {
out.toggle = FooterBarRect{cursorX, top, spec.toggleWidth, boxH};
cursorX += spec.toggleWidth + spec.gap;
} else {
return out; // no room for even the toggle — nothing else can fit either
}
// Count label (passive readout). Suppressed by countWidth <= 0 (no gap consumed then).
if (spec.countWidth > 0) {
if (fitsLeftOf(cursorX, spec.countWidth, rightBound)) {
out.count = FooterBarRect{cursorX, top, spec.countWidth, boxH};
cursorX += spec.countWidth + spec.gap;
}
// If the count does not fit, do NOT advance the cursor past it — the Tail button then
// gets its chance at the same slot (a passive label yields to the interactive button).
}
// Tail button.
if (fitsLeftOf(cursorX, spec.tailWidth, rightBound))
out.tail = FooterBarRect{cursorX, top, spec.tailWidth, boxH};
return out;
}
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) {
// Toggle first (matches the shell's segment sub-hit precedence), then the Tail button. The
// count label is a passive readout — never a hit target.
if (pointIn(px, py, layout.toggle)) return FooterHit::Toggle;
if (pointIn(px, py, layout.tail)) return FooterHit::Tail;
return FooterHit::None;
}
} // namespace reasampler
+116
View File
@@ -0,0 +1,116 @@
#pragma once
// footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4
// footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count
// label, and the Tail button, laid out left-to-right at the footer's left. The panel shell
// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the click dispatch (cycle tail /
// activate a mode); what is NOT DAW-bound — WHERE the toggle box, the count label, and the
// Tail button sit, and which one a click lands on — lives here so it is unit-tested outside
// the DAW (CLAUDE.md §load-bearing split). Mirror of action_bar / mode_switch / prune_button.
//
// -- Footer affordance order (L4, left -> right) -------------------------------
//
// [Arrange|Design] toggle . count label . Tail button . ... . Prune (rightmost, warn)
//
// The two view/session controls (mode toggle, tail) group at the LEFT as the "how this
// panel/capture behaves" cluster; Prune stays isolated at the far RIGHT, warn-colored and
// set apart (it is the only byte-deleting affordance). This module lays out the LEFT group
// ONLY — the rightmost Prune button remains owned by prune_button (computePruneButton), so
// the two never fight over the same pixels. footer_bar reserves a right margin (rightReserve)
// so its own affordances never run under the prune button's region.
//
// The mode toggle is drawn as an N-segment control (2 segments for Arrange|Design; N general).
// footer_bar returns only the toggle's BOX (fit to its text width); the shell hands that box's
// width to the pure mode_switch (computeSegmentRects / hitTestSegment) for the per-segment
// tiling and hit-test, so mode_switch stays the ONE owner of segment geometry. footer_bar
// decides the toggle's placement + overall width; mode_switch subdivides it.
//
// NAME NOTE (brief §name-collision): ButtonRect / FooterRect / SegmentRect / ActionBarRect /
// KitBox / KitButtonBox are already owned in this namespace; grep-checked FooterBar* / FooterHit
// FREE before minting. FooterRect (prune_button) is the input strip type and is REUSED here
// (same concept — the footer strip); the new output/spec/hit types carry the FooterBar* prefix.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include "prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted)
namespace reasampler {
// One placed affordance's pixel rectangle within the footer, top-left origin. A zero-area rect
// (empty()) means "not placed" (the footer was too narrow to host it after the ones before it),
// so the shell draws/hit-tests nothing for it — graceful degradation, mirroring prune_button.
struct FooterBarRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool empty() const { return width <= 0 || height <= 0; }
bool operator==(const FooterBarRect& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
};
// The laid-out footer LEFT group: the mode toggle box, the count label box, and the Tail
// button box, in left-to-right order. Any box may be empty (suppressed) when the footer is
// too narrow to fit it left of the reserved right margin — placement is greedy left-to-right,
// so an earlier affordance survives while a later one drops (the toggle is most important,
// the Tail button drops first on a very narrow footer).
struct FooterBarLayout {
FooterBarRect toggle; // the [Arrange|Design] segmented control's overall box
FooterBarRect count; // the compact per-mode count label (right of the toggle)
FooterBarRect tail; // the Tail button (right of the count label)
bool operator==(const FooterBarLayout& o) const {
return toggle == o.toggle && count == o.count && tail == o.tail;
}
};
// Which footer LEFT-group affordance a point landed on (or None for a miss / a suppressed
// affordance). Prune is NOT here — the shell hit-tests it separately via hitTestPruneButton.
enum class FooterHit { None, Toggle, Tail };
// Layout inputs for the footer LEFT group, in pixels. Defaults are the bank_panel footer
// metrics; the shell passes its own so draw and hit-test share ONE source of truth.
// * toggleWidth — the [Arrange|Design] toggle's overall width. Sized to fit its two
// segment labels comfortably (a NARROW control, per L4 §3 — no longer the
// full-width top header). The shell picks this to fit its text; the pure
// module treats it as a fixed input.
// * countWidth — the compact per-mode count label's width (e.g. "2 tracks"). 0 hides it.
// * tailWidth — the Tail button's width (fits "Tail: Manual 8.0s" comfortably).
// * gap — horizontal gap between adjacent affordances.
// * leftPad — inset from the footer left edge to the toggle's left edge.
// * verticalInset — top/bottom gap inside the footer so the controls read as raised, not
// full-height fills (matches prune_button's verticalInset).
// * rightReserve — pixels reserved at the footer's RIGHT for the prune button + version
// readout region; footer_bar never places an affordance whose right edge
// would cross into (footer.right - rightReserve). Keeps the LEFT group
// clear of the RIGHT prune/version region without those modules coupling.
struct FooterBarSpec {
int toggleWidth = 132;
int countWidth = 64;
int tailWidth = 132;
int gap = 6;
int leftPad = 8;
int verticalInset = 4;
int rightReserve = 168; // clears prune_button (rightInset 84 + width 72) + margin
};
// Lays out the footer LEFT group inside `footer` per `spec`, left-to-right: toggle, then the
// count label, then the Tail button, each `gap` px apart, starting at footer.left + leftPad,
// vertically centred by verticalInset. Greedy: an affordance is placed only if its whole box
// fits left of (footer.right - rightReserve); otherwise it (and, since placement is ordered,
// it alone or the ones after it) is suppressed (empty box). A degenerate footer (width/height
// <= 0) yields an all-empty layout. countWidth <= 0 suppresses the count label (and the gap
// that would precede the Tail button collapses so the Tail sits right after the toggle).
FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec);
// The footer LEFT-group affordance the point (px, py) (SWELL/LICE top-left client coords) lands
// on, or FooterHit::None for a miss (outside every placed box, or on the count label — which is
// a passive readout, not a control). Half-open bounds [x, x+width) x [y, y+height) match
// computeFooterBar so draw and hit-test agree on the same pixels. An empty (suppressed) box
// never claims a point. The shell checks the toggle hit FIRST for a segment sub-hit (via
// mode_switch over the toggle box), then the Tail hit; this returns which region was struck.
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout);
} // namespace reasampler
+23 -18
View File
@@ -13,14 +13,16 @@
// //
// -- Placement contract -------------------------------------------------------- // -- Placement contract --------------------------------------------------------
// //
// The footer already hosts a LEFT-aligned tail-mode label and a RIGHT-aligned // The footer hosts (L4) a LEFT group — the [Arrange|Design] mode toggle, a per-mode
// version readout (bank_panel drawTailFooter). The prune button is a fixed-width // count, and the Tail button (bank_panel footer_bar) — and a RIGHT-aligned version
// button anchored to the RIGHT of the footer, inset from the right edge, sitting // readout (bank_panel drawFooter). The prune button is a fixed-width button anchored
// just LEFT of the version readout's inset region. It never overlaps the tail label // to the RIGHT of the footer, inset from the right edge, sitting just LEFT of the
// at the left. When the footer is too narrow to fit the button without colliding // version readout's inset region and set APART from the benign left group. It never
// with the left inset, the button is suppressed (empty rect) rather than drawn on // overlaps the left group (footer_bar reserves rightReserve px at the right to match).
// top of the label — the action is always reachable via its bindable command, so a // When the footer is too narrow to fit the button without colliding with the left
// hidden button is a graceful degradation, not a lost affordance. // inset, the button is suppressed (empty rect) rather than drawn on top — the action
// is always reachable via its bindable command, so a hidden button is a graceful
// degradation, not a lost affordance.
namespace reasampler { namespace reasampler {
@@ -59,21 +61,24 @@ struct ButtonRect {
// * buttonWidth — the button's fixed width. // * buttonWidth — the button's fixed width.
// * rightInset — gap from the footer's right edge to the button's right edge (the // * rightInset — gap from the footer's right edge to the button's right edge (the
// button sits left of this inset, clearing the right-aligned version // button sits left of this inset, clearing the right-aligned version
// readout). COUPLED TO drawTailFooter (bank_panel.cpp): the version // readout). COUPLED TO drawFooter (bank_panel.cpp): the version readout
// readout uses `vrc.right -= 8` (8 px right margin). The button's // uses an 8 px right margin. The button's right edge lands at
// right edge lands at footer.right - 84, i.e. 76 px left of the // footer.right - 84, i.e. 76 px left of the readout's right margin —
// readout's right margin — enough clearance for the ~10-char label. // enough clearance for the ~10-char label. ALSO COUPLED to
// If the version readout's inset changes in drawTailFooter, update // FooterBarSpec::rightReserve (footer_bar.h): the L4 footer-left group
// this value to maintain clearance. // (mode toggle + count + Tail) reserves that many px at the right so it
// never runs under this button; rightReserve must exceed rightInset +
// buttonWidth. If the version readout's inset changes in drawFooter,
// update this value to maintain clearance.
// * verticalInset — top/bottom gap inside the footer (the button is shorter than the // * verticalInset — top/bottom gap inside the footer (the button is shorter than the
// strip so it reads as a raised control, not a full-height fill). // strip so it reads as a raised control, not a full-height fill).
// * minLeftInset — the button's left edge must stay at least this far from the footer // * minLeftInset — the button's left edge must stay at least this far from the footer
// left edge (reserving room for the left-aligned tail label). If the // left edge (reserving room for the L4 footer-left group). If the button
// button would encroach past this, computePruneButton yields an empty // would encroach past this, computePruneButton yields an empty rect
// rect (button suppressed — see header placement contract). // (button suppressed — see header placement contract).
struct PruneButtonSpec { struct PruneButtonSpec {
int buttonWidth = 72; int buttonWidth = 72;
int rightInset = 84; // COUPLED: version readout in drawTailFooter uses vrc.right -= 8 int rightInset = 84; // COUPLED: version readout in drawFooter uses an 8 px right margin
int verticalInset = 4; int verticalInset = 4;
int minLeftInset = 120; int minLeftInset = 120;
}; };
+247
View File
@@ -0,0 +1,247 @@
// Standalone tests for reasampler::footer_bar — no REAPER, no test framework. Same fast loop
// as the sibling pure tests (prune_button / mode_switch / action_bar): assert the L4 footer
// LEFT-group layout (toggle . count . Tail) and hit-testing directly.
//
// Covers (L4 brief §footer test cases):
// * Layout: toggle + count + Tail placed left-to-right in the specified order without
// overlap, starting at leftPad, vertically inset; the narrow toggle sits at the LEFT.
// * The group stays clear of the reserved RIGHT region (prune + version) — nothing crosses
// footer.right - rightReserve.
// * Suppression: a too-narrow footer drops later affordances (Tail first, then count),
// the toggle surviving longest; a degenerate footer yields an all-empty layout.
// * 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.
#include "../src/footer_bar.h"
#include <cstdio>
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)
// A spec with round numbers so expected pixels are hand-checkable.
static FooterBarSpec roundSpec() {
FooterBarSpec s;
s.toggleWidth = 130;
s.countWidth = 60;
s.tailWidth = 130;
s.gap = 6;
s.leftPad = 8;
s.verticalInset = 4;
s.rightReserve = 168;
return s;
}
// --- Layout: wide footer, all three placed, left-to-right ---------------------
// A wide footer fits all three. Verify order, gaps, no overlap, vertical inset, and that the
// Tail (last) stays clear of the reserved right region.
static void testWideFooterAllPlaced() {
const FooterBarSpec s = roundSpec();
// Need room for toggle(130)+gap(6)+count(60)+gap(6)+tail(130) = 332 from leftPad(8) => 340,
// plus rightReserve(168). Width 700 is comfortable.
FooterRect f{0, 100, 700, 26};
const FooterBarLayout L = computeFooterBar(f, s);
const int top = 100 + 4, boxH = 26 - 8; // 104, 18
CHECK((L.toggle == FooterBarRect{8, top, 130, boxH}));
CHECK((L.count == FooterBarRect{8 + 130 + 6, top, 60, boxH})); // x = 144
CHECK((L.tail == FooterBarRect{8 + 130 + 6 + 60 + 6, top, 130, boxH})); // x = 210
// Order + no overlap: each starts at-or-after the previous end.
CHECK(L.count.x >= L.toggle.x + L.toggle.width);
CHECK(L.tail.x >= L.count.x + L.count.width);
// The whole group clears the reserved right region.
CHECK(L.tail.x + L.tail.width <= f.x + f.width - s.rightReserve);
}
// Origin offset honoured: the group anchors to THIS footer's left, not 0.
static void testOffsetFooterAnchorsLeft() {
const FooterBarSpec s = roundSpec();
FooterRect f{20, 200, 700, 26};
const FooterBarLayout L = computeFooterBar(f, s);
CHECK(L.toggle.x == 20 + 8);
CHECK(!L.toggle.empty() && !L.count.empty() && !L.tail.empty());
}
// --- Suppression: narrowing drops later affordances first ---------------------
// A footer wide enough for toggle + count but not the Tail: Tail suppressed, toggle+count stay.
static void testTailSuppressedWhenTight() {
const FooterBarSpec s = roundSpec();
// Right bound = width - 168. Toggle ends at 8+130=138; count ends at 144+60=204.
// Tail would start at 210 and end at 340; need 340 <= width-168 => width >= 508 for Tail.
// count needs 204 <= width-168 => width >= 372. Pick width 480: count fits, Tail does not.
FooterRect f{0, 0, 480, 26};
const FooterBarLayout L = computeFooterBar(f, s);
CHECK(!L.toggle.empty());
CHECK(!L.count.empty());
CHECK(L.tail.empty());
}
// Narrower still: only the toggle survives (count and Tail both drop). When the count does not
// fit, the Tail is offered the count's slot — but if the Tail also does not fit there, both are
// empty and only the toggle remains.
static void testOnlyToggleSurvives() {
const FooterBarSpec s = roundSpec();
// Toggle needs 138 <= width-168 => width >= 306. count needs width >= 372.
// At the count's slot (x=144) the Tail (130) would end at 274; needs 274 <= width-168 =>
// width >= 442. Pick width 330: toggle fits (306), count does not (372), Tail-at-count-slot
// does not (442) -> only toggle.
FooterRect f{0, 0, 330, 26};
const FooterBarLayout L = computeFooterBar(f, s);
CHECK(!L.toggle.empty());
CHECK(L.count.empty());
CHECK(L.tail.empty());
}
// When the count does NOT fit but the Tail WOULD fit at the count's slot, the Tail takes it
// (a passive label yields to the interactive button). Right after the toggle, no count gap.
static void testTailTakesCountSlotWhenCountDrops() {
const FooterBarSpec s = roundSpec();
// Want: count does not fit, Tail-at-count-slot fits. count fits at width >= 372.
// Tail at count slot x=144, ends 274, fits at width >= 442. Choose width 420:
// 372 > 420? no — count fits at 420. Need width in [442-eps? ] hmm: count fits iff
// width>=372, Tail-at-slot fits iff width>=442. For "count drops but tail-at-slot fits"
// we need width < 372 AND width >= 442 — impossible with countWidth==tailWidth region.
// So shrink countWidth to make its slot further right than tail's need. Use a spec where
// countWidth is large so it drops before a smaller Tail.
FooterBarSpec s2 = s;
s2.countWidth = 200; // large passive label
s2.tailWidth = 80; // small button
// toggle ends 138. count slot x = 144, count ends 344; fits iff 344 <= width-168 =>
// width >= 512. Tail at count slot x=144 ends 224; fits iff width >= 392.
// Choose width 420: count drops (needs 512), Tail-at-slot fits (needs 392).
FooterRect f{0, 0, 420, 26};
const FooterBarLayout L = computeFooterBar(f, s2);
CHECK(!L.toggle.empty());
CHECK(L.count.empty());
CHECK(!L.tail.empty());
// Tail sits at the count's slot (right after the toggle + gap), NOT past a phantom count.
CHECK(L.tail.x == L.toggle.x + L.toggle.width + s2.gap);
}
// A degenerate footer yields an all-empty layout (nothing placed, no crash).
static void testDegenerateFooterEmpty() {
const FooterBarSpec s = roundSpec();
const FooterBarLayout a = computeFooterBar(FooterRect{0, 0, 0, 26}, s);
CHECK(a.toggle.empty() && a.count.empty() && a.tail.empty());
const FooterBarLayout b = computeFooterBar(FooterRect{0, 0, 700, 0}, s);
CHECK(b.toggle.empty() && b.count.empty() && b.tail.empty());
// Height <= 2*verticalInset -> boxH <= 0 -> all empty.
const FooterBarLayout c = computeFooterBar(FooterRect{0, 0, 700, 8}, s);
CHECK(c.toggle.empty() && c.count.empty() && c.tail.empty());
}
// A footer too narrow even for the toggle: all empty (toggle drops last, so nothing survives).
static void testTooNarrowForToggle() {
const FooterBarSpec s = roundSpec();
// Toggle fits iff 8+130 <= width-168 => width >= 306. Width 200 suppresses everything.
const FooterBarLayout L = computeFooterBar(FooterRect{0, 0, 200, 26}, s);
CHECK(L.toggle.empty() && L.count.empty() && L.tail.empty());
}
// countWidth <= 0 hides the count label and the Tail sits right after the toggle (no count gap).
static void testCountHiddenByZeroWidth() {
FooterBarSpec s = roundSpec();
s.countWidth = 0;
FooterRect f{0, 0, 700, 26};
const FooterBarLayout L = computeFooterBar(f, s);
CHECK(!L.toggle.empty());
CHECK(L.count.empty());
CHECK(!L.tail.empty());
CHECK(L.tail.x == L.toggle.x + L.toggle.width + s.gap); // right after the toggle
}
// --- Hit-test -----------------------------------------------------------------
static void testHitToggleAndTail() {
const FooterBarSpec s = roundSpec();
FooterRect f{0, 100, 700, 26};
const FooterBarLayout L = computeFooterBar(f, s);
// Toggle interior.
CHECK(hitTestFooterBar(L.toggle.x, L.toggle.y, L) == FooterHit::Toggle);
CHECK(hitTestFooterBar(L.toggle.x + L.toggle.width - 1,
L.toggle.y + L.toggle.height - 1, L) == FooterHit::Toggle);
// Tail interior.
CHECK(hitTestFooterBar(L.tail.x + L.tail.width / 2,
L.tail.y + L.tail.height / 2, L) == FooterHit::Tail);
// The count label is a passive readout — never a hit target.
CHECK(hitTestFooterBar(L.count.x + L.count.width / 2,
L.count.y + L.count.height / 2, L) == FooterHit::None);
// Between the toggle and the count (the gap) is a clean miss.
CHECK(hitTestFooterBar(L.toggle.x + L.toggle.width, L.toggle.y, L) == FooterHit::None);
}
// Half-open bounds: far edges excluded; points outside every box miss.
static void testHitEdgesAndOutside() {
const FooterBarSpec s = roundSpec();
FooterRect f{0, 100, 700, 26};
const FooterBarLayout L = computeFooterBar(f, s);
CHECK(hitTestFooterBar(L.toggle.x - 1, L.toggle.y, L) == FooterHit::None);
CHECK(hitTestFooterBar(L.tail.x + L.tail.width, L.tail.y, L) == FooterHit::None); // right edge excl
CHECK(hitTestFooterBar(L.toggle.x, L.toggle.y - 1, L) == FooterHit::None); // above
CHECK(hitTestFooterBar(L.toggle.x, L.toggle.y + L.toggle.height, L) == FooterHit::None); // below excl
// Far right (the prune/version region) is not this module's — a clean None here.
CHECK(hitTestFooterBar(f.x + f.width - 10, L.toggle.y, L) == FooterHit::None);
}
// A suppressed box claims no point — a Tail hit-test where the Tail was dropped is None.
static void testSuppressedClaimsNothing() {
const FooterBarSpec s = roundSpec();
FooterRect f{0, 0, 480, 26}; // Tail suppressed (see testTailSuppressedWhenTight)
const FooterBarLayout L = computeFooterBar(f, s);
CHECK(L.tail.empty());
// A point where the Tail WOULD have been claims nothing.
CHECK(hitTestFooterBar(210 + 10, 104, L) != FooterHit::Tail);
}
// --- Resize sweep: no overlap or cross-into-reserved across a width range -----
static void testResizeSweepNoOverlap() {
const FooterBarSpec s = roundSpec();
const int rightBoundFor = -s.rightReserve; // + width applied per iteration
for (int w = 40; w <= 1200; w += 7) {
FooterRect f{0, 0, w, 26};
const FooterBarLayout L = computeFooterBar(f, s);
const int rightBound = f.x + w + rightBoundFor; // = w - rightReserve
// Order + no overlap among placed boxes.
if (!L.toggle.empty() && !L.count.empty())
CHECK(L.count.x >= L.toggle.x + L.toggle.width);
if (!L.count.empty() && !L.tail.empty())
CHECK(L.tail.x >= L.count.x + L.count.width);
if (!L.toggle.empty() && L.count.empty() && !L.tail.empty())
CHECK(L.tail.x >= L.toggle.x + L.toggle.width);
// Nothing placed crosses into the reserved right region.
if (!L.toggle.empty()) CHECK(L.toggle.x + L.toggle.width <= rightBound);
if (!L.count.empty()) CHECK(L.count.x + L.count.width <= rightBound);
if (!L.tail.empty()) CHECK(L.tail.x + L.tail.width <= rightBound);
// A later affordance is never placed while an earlier one is dropped (greedy order):
// count/tail present implies toggle present.
if (!L.count.empty() || !L.tail.empty()) CHECK(!L.toggle.empty());
}
}
int main() {
testWideFooterAllPlaced();
testOffsetFooterAnchorsLeft();
testTailSuppressedWhenTight();
testOnlyToggleSurvives();
testTailTakesCountSlotWhenCountDrops();
testDegenerateFooterEmpty();
testTooNarrowForToggle();
testCountHiddenByZeroWidth();
testHitToggleAndTail();
testHitEdgesAndOutside();
testSuppressedClaimsNothing();
testResizeSweepNoOverlap();
if (g_fail == 0) std::printf("footer_bar: all tests passed\n");
else std::printf("footer_bar: %d CHECK(s) FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}