From 8f4a84ac79448cb6d142d3095d825c3bf8dd05f1 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 22:42:56 -0400 Subject: [PATCH 1/2] 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. --- CMakeLists.txt | 22 +- src/action_bar.h | 48 ++-- src/bank_panel.cpp | 552 +++++++++++++++++++++----------------- src/footer_bar.cpp | 69 +++++ src/footer_bar.h | 116 ++++++++ src/prune_button.h | 41 +-- tests/test_footer_bar.cpp | 247 +++++++++++++++++ 7 files changed, 817 insertions(+), 278 deletions(-) create mode 100644 src/footer_bar.cpp create mode 100644 src/footer_bar.h create mode 100644 tests/test_footer_bar.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 59273f8..870c145 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -370,6 +370,21 @@ target_include_directories(component_geometry PUBLIC src) add_library(action_bar STATIC src/action_bar.cpp) 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). # --------------------------------------------------------------------------- @@ -484,6 +499,10 @@ add_executable(action_bar_tests tests/test_action_bar.cpp) target_link_libraries(action_bar_tests PRIVATE action_bar) 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). # --------------------------------------------------------------------------- @@ -528,8 +547,9 @@ add_library(reaper_reasampler MODULE src/owned_manifest.cpp src/drag_out_win.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}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/src/action_bar.h b/src/action_bar.h index 5691bc6..59c05c1 100644 --- a/src/action_bar.h +++ b/src/action_bar.h @@ -1,12 +1,18 @@ #pragma once // 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 -// layout, not a re-skin) groups the M11 action-trigger button inventory BY TASK — a compact -// bar of clusters (capture / placement / maintenance) instead of one flat equal-tiled strip -// (the M11 action_buttons row this supersedes for the panel's action inventory). Each button -// carries a label sub-rect and a keybinding-help MICRO sub-rect ("icon+label, keybinding as a -// micro sub-label" — the L2 contract), and the bar degrades gracefully on a narrow panel by -// dropping WHOLE trailing buttons (never clipping) so the frequent capture cluster survives. +// TASK-GROUPED toolbars (Phase L, L2 + L4). L2's dock-panel layout redesign (DS-3: a thorough +// layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact bar of +// clusters instead of one flat equal-tiled strip (the M11 action_buttons row this supersedes +// for the panel's action inventory). Each button carries a label sub-rect and a keybinding-help +// MICRO sub-rect ("icon+label, keybinding as a micro sub-label" — the L2 contract), and the bar +// degrades gracefully on a narrow panel by dropping WHOLE trailing buttons (never clipping) so +// 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 // window, the L1-kit draws, and the NamedCommandLookup/Main_OnCommand dispatch — all @@ -32,13 +38,22 @@ namespace reasampler { -// The task cluster a button belongs to (the L2 "group by task" mandate). Capture is the -// primary/frequent gesture (leftmost), then placement, then the rarer maintenance actions. -// The order here IS the left-to-right cluster order in the bar. +// The task cluster a button belongs to (the L2 "group by task" mandate). The order here is +// NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum +// 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 { - Capture, // capture item / track / realtime / batch — the primary gesture - Placement, // insert at cursor / insert-conform — placing a bank sample on the timeline + Capture, // capture item / track / realtime / batch — top toolbar, primary gesture + Placement, // insert at cursor / insert-conform — top toolbar, placing a sample 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 @@ -90,10 +105,11 @@ struct ActionBarSlot { }; // 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 -// (no gap emitted for it). The flat action index a slot carries is the running sum across -// clusters (cluster 0's buttons are indices [0, counts[0]), etc.), so the shell's flat action -// table lines up with the slots by index. +// in the left-to-right order it wants them drawn (top toolbar: Capture then Placement; bottom +// toolbar: Tagging then Switching); a cluster with count 0 is skipped (no gap emitted for it). +// The flat action index a slot carries is the running sum across clusters (cluster 0's buttons +// are indices [0, counts[0]), etc.), so the shell's flat action table lines up with the slots +// by index. struct ClusterSpec { ActionCluster cluster = ActionCluster::Capture; int count = 0; diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 77e4131..33a6981 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -58,6 +58,7 @@ #include "capture_paths.h" #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 "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 "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) #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) -// --- Mode-switch header (D5) -------------------------------------------------- -constexpr int kHeaderHeight = 30; +// --- Footer (Phase L, L4) ----------------------------------------------------- +// 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) ------------------------------------------- -constexpr int kFooterHeight = 26; +// --- Toolbars (Phase L, L4) --------------------------------------------------- +// 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) ------------------------------------------------- -// 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) ------------------- +// --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) ------- // -// The client area, top to bottom: mode-switch header (kHeaderHeight) | split body | -// action bar (kButtonStripHeight) | tail footer (kFooterHeight). The split body holds the -// pool region (top) and the named-banks region (bottom). Each region opens with a REGION -// HEADER 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. +// The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) | +// split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer +// (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool +// region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER +// 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 kTabStripHeight = 26; // the named-banks tab strip band 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. enum class HoverKind { 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, FullHtPool, // pool region full-height toggle FullHtBanks, // banks region full-height toggle CreateBank, // the "+" create-bank button Tab, // a named-bank tab (index = tab ordinal) - Footer, // the tail-mode toggle strip - ModeSegment, // a mode-switch segment (index = segment ordinal) + TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone) + ModeSegment, // a footer mode-toggle segment (index = segment ordinal) }; struct Hover { @@ -506,52 +515,27 @@ void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt, text(bmp, box, txt, font, role, align); } -// --- Mode-switch header (D5, unchanged) --------------------------------------- - -HeaderRect panelHeader(int w) { return HeaderRect{0, 0, w, kHeaderHeight}; } +// --- Mode toggle (D5; relocated to the footer at L4) -------------------------- int modeCount() { if (!g_panel.session) return 0; return static_cast(g_panel.session->view().modes().size()); } -void drawModeSwitch(LICE_IBitmap* bmp, int w) { - if (!g_panel.session) return; - const ViewModeModel& view = g_panel.session->view(); - const std::vector& modes = view.modes().all(); - const int n = static_cast(modes.size()); - - // Header band — the base canvas (L2: kit bg/base surface). - fillSurface(bmp, KitBox{0, 0, w, kHeaderHeight}, Role::BgBase, InteractionState::Rest); - if (n <= 0) return; - - const HeaderRect header = panelHeader(w); - const std::vector 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(i)]; - const Mode& mode = modes[static_cast(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); - } +// --- Top toolbar band (L4) ---------------------------------------------------- +// +// The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate +// (height 0) when the client is too short to host it above the split body. +ActionBarRect topToolbarRect(int w) { + ActionBarRect s; + s.x = 0; + s.y = 0; + s.width = w; + s.height = kTopToolbarHeight; + return s; } -// --- Tail-mode footer (T1, unchanged) ----------------------------------------- +// --- Footer (L4) -------------------------------------------------------------- RECT panelFooter(int w, int h) { RECT rc{}; @@ -559,7 +543,8 @@ RECT panelFooter(int w, int h) { rc.right = w; rc.top = h - kFooterHeight; 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; } @@ -569,47 +554,36 @@ TailSetting currentTail() { return g_panel.session ? g_panel.session->tail() : TailSetting{}; } -// Draws the tail-mode toggle into the footer strip: a filled band, a top divider, -// and the current mode's label ("Tail: Off / Auto / Manual Xs") from the pure -// tail_control module. READ-ONLY: reads session->tail(); the input handlers mutate it. -void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { +// The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client +// size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate. +FooterBarLayout footerBarLayoutFor(int w, int 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 - // cycles the tail mode (the strip IS the toggle control). - const InteractionState footerState = - hoverState(g_panel.hovered, HoverKind::Footer, -1); - fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel, footerState); - LICE_Line(bmp, f.left, f.top, f.right, f.top, - toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); - - // Tail-mode toggle, left-aligned (the interactive control — footer clicks cycle it). - const std::string label = tailToggleLabel(currentTail()); - kitText(bmp, KitBox{f.left + 8, f.top, (f.right - f.left) - 8, f.bottom - f.top}, - label.c_str(), Font::Label, Role::TextPrimary, Align::Left); - - // 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 per-mode membership count that travels with the toggle (L4 §3): the number of leaves +// tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no +// session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.) +// A display-only tally over the model's public membership map — no model semantics duplicated. +int activeModeMemberCount() { + if (!g_panel.session) return 0; + const ViewModeModel& view = g_panel.session->view(); + const std::string& active = view.activeModeId(); + if (active.empty()) return 0; + int n = 0; + for (const auto& [guid, m] : view.membership().all()) + if (m.modeIds.count(active) != 0) ++n; + return n; } // 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 -// footer is degenerate or too narrow to place the button clear of the tail label — the -// action stays reachable via its bindable command, so a suppressed button is graceful. -// Clearance from the version readout: PruneButtonSpec::rightInset (84) places the button -// right edge 76 px left of the readout's 8 px right margin — see coupling comments in -// prune_button.h and drawTailFooter above. +// of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate +// 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. Kept +// set apart at the RIGHT (footer_bar reserves the matching space at its right so the two +// groups never overlap). See prune_button.h §Placement contract. ButtonRect pruneButtonRectFor(int w, int h) { const RECT f = panelFooter(w, h); 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{}); } -// Draws the prune button into the footer (called after drawTailFooter fills the strip). -// No-op when the button is suppressed (footer too narrow). READ-ONLY: draws only. -void drawPruneButton(LICE_IBitmap* bmp, int w, int h) { - const ButtonRect b = pruneButtonRectFor(w, h); - if (b.empty()) return; +// Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design] +// toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and +// the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button +// set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it. +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 - // in the footer, honoring hover. Its label draws inside the button (kit centers it). - const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1); - const KitButtonBox box{KitBox{b.x, b.y, b.width, b.height}}; - drawButton(bmp, box, "Prune", state, /*warn=*/true); + // Footer band + hairline top divider (the base persistent-controls strip). + fillSurface(bmp, KitBox{f.left, f.top, w, kFooterHeight}, Role::BgPanel, + InteractionState::Rest); + LICE_Line(bmp, f.left, f.top, f.right, f.top, + 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& modes = view.modes().all(); + const int n = static_cast(modes.size()); + const HeaderRect th{fb.toggle.x, fb.toggle.y, fb.toggle.width, fb.toggle.height}; + const std::vector segs = computeSegmentRects(th, n); + const std::string& activeId = view.activeModeId(); + for (int i = 0; i < static_cast(segs.size()); ++i) { + const SegmentRect& s = segs[static_cast(i)]; + const Mode& mode = modes[static_cast(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. -// Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust) -// so both agree on the hit target. +// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the +// scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The +// Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar). bool pointInFooter(int x, int y) { if (!g_panel.hwnd) return false; RECT cr{}; @@ -654,46 +694,41 @@ void markTailDirty() { 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 -// compact toolbar of clusters — Capture (the primary gesture), Placement, Maintenance — -// each button drawn through the L1 kit's drawButton with the action name (Font::Label) and -// its live key binding on a Micro sub-row ("icon+label, keybinding as a micro sub-label" — -// the L2 contract). The pure action_bar module 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 (unchanged from -// M11 — L2 re-places and re-draws, it does not re-wire behavior). +// L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin) +// across TWO toolbars, BOTH drawn through the pure action_bar module: +// * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the +// two acts the tool exists for (L4 §1); +// * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the +// footer, in the space capture/placement vacated (L4 §2). +// Each button is drawn with its action name (Font::Label) and live key binding on a Micro +// 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 -// string (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct -// on stable and beta and adds NO second registration. A cmd of 0 (action not registered on -// this channel) draws Disabled and no-ops on click. +// Each button resolves its command id at RUNTIME from the composed named-command string +// (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable +// and beta and adds NO second registration. A cmd of 0 (action not registered on this channel) +// 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 -// prefix at fire time — never a hardcoded numeric id), its terse on-button label, and the -// task cluster it belongs to. The order of this list IS the flat action index the pure -// action_bar slots carry, so the list must be built cluster-by-cluster in ActionCluster -// order (Capture, then Placement, then Maintenance). +// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix +// at fire time — never a hardcoded numeric id), its terse on-button label, and the task cluster +// it belongs to. The order of a toolbar's row list IS the flat action index the pure action_bar +// slots carry, so each list is built cluster-by-cluster in its toolbar's cluster order. struct ActionBarRow { std::string suffix; std::string shortLabel; ActionCluster cluster = ActionCluster::Capture; }; -// The full action inventory, grouped by task and TABLE-DRIVEN where possible: the capture -// scopes come from captureActionTable() (render_settings, pure), then batch capture and -// realtime capture round out the Capture cluster; the two insert variants form Placement; -// re-capture + cancel-realtime form Maintenance. Built once per draw/click — cheap (a -// handful of small strings) and always in step with the registered families. -// -// 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 actionBarRows() { +// The TOP toolbar inventory: Capture (item / track / batch items / batch razor / RT) then +// Placement (insert / insert-conform). Capture scopes come from captureActionTable() +// (render_settings, pure); the rest are the registered M11/M6 commands. RECONCILED against the +// actually-REGISTERED commands — "resample-and-mute" / "null-test verify" are not registered +// commands and drag-out is a mouse gesture, so none are placed. Built once per draw/click. +std::vector topBarRows() { std::vector rows; // Capture cluster — the primary gesture, leftmost. for (const CaptureActionDef& def : captureActionTable()) { @@ -705,49 +740,75 @@ std::vector actionBarRows() { rows.push_back({"CAPTURE_BATCH_ITEMS", "Batch Items", ActionCluster::Capture}); rows.push_back({"CAPTURE_BATCH_RAZOR", "Batch Razor", 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_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; } -// The cluster button-count specs for a given row set, in ActionCluster order (the order the -// rows were built in), so the pure action_bar's flat index lines up with actionBarRows(). +// The BOTTOM toolbar inventory: the Design-View action family, grouped Tagging then Switching +// (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 bottomBarRows() { + std::vector 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 actionBarClusters(const std::vector& rows) { - int nCap = 0, nPlace = 0, nMaint = 0; + int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0; for (const ActionBarRow& r : rows) { - if (r.cluster == ActionCluster::Capture) ++nCap; - else if (r.cluster == ActionCluster::Placement) ++nPlace; - else ++nMaint; + switch (r.cluster) { + case ActionCluster::Capture: ++nCap; break; + case ActionCluster::Placement: ++nPlace; break; + case ActionCluster::Maintenance: ++nMaint; break; + case ActionCluster::Tagging: ++nTag; break; + case ActionCluster::Switching: ++nSwitch; break; + } } return { {ActionCluster::Capture, nCap}, {ActionCluster::Placement, nPlace}, {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 -// shared by draw and hit-test. +// The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared +// by both toolbars' draw and hit-test (identical button shape top and bottom). const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/16, /*sidePad=*/8, /*verticalInset=*/3, /*bindingHeight=*/11, /*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. -ActionBarRect actionBarRect(int w, int h) { +ActionBarRect bottomToolbarRect(int w, int h) { ActionBarRect s; const RECT footer = panelFooter(w, h); const int footerTop = (footer.top < footer.bottom) ? footer.top : h; s.x = 0; s.width = w; - s.height = kButtonStripHeight; - s.y = footerTop - kButtonStripHeight; - // Keep the bar below the mode-switch header; if the client is too short, collapse it. - if (s.y < kHeaderHeight) { s.y = footerTop; s.height = 0; } + s.height = kBottomToolbarHeight; + s.y = footerTop - kBottomToolbarHeight; + // Keep the bar below the top toolbar; if the client is too short, collapse it. + if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; } return s; } @@ -769,21 +830,23 @@ std::string barBindingText(int cmd) { return {}; } -// Draws the task-grouped action bar through the L1 kit: a bg/panel band, then each visible -// button as a kit drawButton (rest/hover/disabled) with the action NAME on the label row and -// the key binding (or "unbound") on the Micro sub-row. Overflow drops WHOLE trailing buttons -// (the pure layout returns only the buttons that fit), so nothing is drawn clipped. -void drawActionBar(LICE_IBitmap* bmp, int w, int h) { - const ActionBarRect bar = actionBarRect(w, h); +// Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button +// as a kit drawButton (rest/hover/disabled) with the action NAME on the label row and the key +// binding (or "unbound") on the Micro sub-row. Overflow drops WHOLE trailing buttons (the pure +// layout returns only the buttons that fit), so nothing is drawn clipped. `hoverKind` selects +// which HoverKind this bar's buttons use (TopBarButton / BottomBarButton) so the two toolbars' +// 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& rows, HoverKind hoverKind, bool topDivider) { 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}; 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); - const std::vector rows = actionBarRows(); const std::vector clusters = actionBarClusters(rows); const std::vector 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.) InteractionState state = InteractionState::Rest; if (cmd == 0) state = InteractionState::Disabled; - else if (g_panel.hovered.kind == HoverKind::ActionBarButton && - g_panel.hovered.index == s.index) + else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index) state = InteractionState::Hover; // 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. const std::string binding = barBindingText(cmd); const std::string sub = formatButtonLabel("", binding); // "" + " (unbound)" / " " - // formatButtonLabel prefixes with the name; with an empty name it yields - // " (unbound)" or " " — trim the leading spaces for the sub-row. std::size_t start = sub.find_first_not_of(' '); const std::string shown = (start == std::string::npos) ? sub : sub.substr(start); 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. -int actionBarHit(int x, int y) { - 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); +// The flat action index under (x, y) in `bar` for the given row set, or -1 (miss). Pure hit-test. +int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector& rows) { if (bar.height <= 0) return -1; - const std::vector rows = actionBarRows(); 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 -// contract (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true -// iff the click was inside the bar band (handled, or a harmless gap/overflow/unregistered -// no-op), so the caller stops before grid handling. -bool handleActionBarClick(int x, int y) { - if (!g_panel.hwnd) return false; - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left, h = cr.bottom - cr.top; - const ActionBarRect bar = actionBarRect(w, h); +// Routes a click in a toolbar to the hit button's action, fired through the command-id contract +// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click +// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the +// caller stops before grid handling. `rows` is the toolbar's inventory. +bool handleToolbarClick(int x, int y, const ActionBarRect& bar, + const std::vector& rows) { if (bar.height <= 0) return false; - - const int hit = actionBarHit(x, y); + const int hit = toolbarHit(x, y, bar, rows); if (hit < 0) { - // Inside the band but in a gap / overflow dead-zone: claim it so it never falls - // through to the grid. Outside the band: not ours. + // Inside the band but in a gap / overflow dead-zone: claim it so it never falls through + // to the grid. Outside the band: not ours. return y >= bar.y && y < bar.y + bar.height && x >= bar.x && x < bar.x + bar.width; } - const std::vector rows = actionBarRows(); const int cmd = resolveBarCommandId(rows[static_cast(hit)]); if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); 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 // 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 rc{}; rc.left = 0; rc.right = w; - rc.top = kHeaderHeight; - // The body ends at the action bar (L2), which itself sits above the tail footer. When - // 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.top = kTopToolbarHeight; + const ActionBarRect bar = bottomToolbarRect(w, h); rc.bottom = bar.y; if (rc.bottom < rc.top) rc.bottom = rc.top; return rc; @@ -1207,10 +1254,14 @@ void paintPanel(HWND hwnd, HDC hdc) { } } - drawModeSwitch(&bmp, w); - drawActionBar(&bmp, w, h); // L2 task-grouped action bar, above the footer - drawTailFooter(&bmp, w, h); - drawPruneButton(&bmp, w, h); // R3: raised over the footer strip + // L4 three-zone chrome: TOP toolbar (capture + placement), BOTTOM toolbar (Design-View + // verbs), then the footer (mode toggle + count + Tail button + Prune). Drawn last so they + // sit over the split body's edges. + 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); } @@ -1912,16 +1963,35 @@ bool handlePoolChromeClick(int x, int y, const RECT& region) { return false; } -// Applies a left-click at (x, y): route to mode switch / footer / region chrome / -// grid selection, and arm a potential drag when the click lands on a selected cell. +// The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by +// 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) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; - // Mode-switch header (D5) takes precedence. - if (g_panel.session) { - const int seg = hitTestSegment(x, y, panelHeader(w), modeCount()); + // TOP toolbar (capture + placement) takes precedence — a button fires its registered + // action via the command-id contract; the band is claimed whole (a gap/overflow miss is a + // 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) { const std::vector& modes = g_panel.session->view().modes().all(); if (seg < static_cast(modes.size())) { @@ -1931,14 +2001,23 @@ void handleClick(int x, int y) { } return; } - } - // Prune button (R3): checked BEFORE the footer's tail-cycle so a click on the button - // fires prune, not a tail cycle. Fires the "Prune bank folder" action THROUGH its - // registered command id (fork R-E: dispatch the command, do not call 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 FooterBarLayout fb = footerBarLayoutFor(w, h); + if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) { + // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates + // the SESSION's tail setting (capture reads it; persist saves it with the project) + // 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); if (hitTestPruneButton(x, y, pb)) { 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 - // (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail - // setting (which the capture actions read and persist saves with the project) and - // 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; + // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the + // command-id contract. Claimed whole like the top toolbar. + if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return; // Region chrome (headers, tab strip, buttons). if (poolShown()) { @@ -2210,22 +2274,24 @@ Hover resolveHover(int x, int y) { GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; - // Mode-switch header segments. - 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). + // TOP toolbar buttons (matching the click order — first zone top-to-bottom). { + 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); if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; } - // Tail footer strip. - if (pointInFooter(x, y)) return Hover{HoverKind::Footer, -1}; - // Action bar. + // BOTTOM toolbar buttons. { - const int hit = actionBarHit(x, y); - if (hit >= 0) return Hover{HoverKind::ActionBarButton, hit}; + const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows()); + if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit}; } // Region chrome: full-height toggles, create button, tabs. if (poolShown()) { diff --git a/src/footer_bar.cpp b/src/footer_bar.cpp new file mode 100644 index 0000000..b1e6824 --- /dev/null +++ b/src/footer_bar.cpp @@ -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 diff --git a/src/footer_bar.h b/src/footer_bar.h new file mode 100644 index 0000000..bc24342 --- /dev/null +++ b/src/footer_bar.h @@ -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 diff --git a/src/prune_button.h b/src/prune_button.h index 7409884..ff0e414 100644 --- a/src/prune_button.h +++ b/src/prune_button.h @@ -13,14 +13,16 @@ // // -- Placement contract -------------------------------------------------------- // -// The footer already hosts a LEFT-aligned tail-mode label and a RIGHT-aligned -// version readout (bank_panel drawTailFooter). The prune button is a fixed-width -// button anchored to the RIGHT of the footer, inset from the right edge, sitting -// just LEFT of the version readout's inset region. It never overlaps the tail label -// at the left. When the footer is too narrow to fit the button without colliding -// with the left inset, the button is suppressed (empty rect) rather than drawn on -// top of the label — the action is always reachable via its bindable command, so a -// hidden button is a graceful degradation, not a lost affordance. +// The footer hosts (L4) a LEFT group — the [Arrange|Design] mode toggle, a per-mode +// count, and the Tail button (bank_panel footer_bar) — and a RIGHT-aligned version +// readout (bank_panel drawFooter). The prune button is a fixed-width button anchored +// to the RIGHT of the footer, inset from the right edge, sitting just LEFT of the +// version readout's inset region and set APART from the benign left group. It never +// overlaps the left group (footer_bar reserves rightReserve px at the right to match). +// When the footer is too narrow to fit the button without colliding with the left +// 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 { @@ -59,21 +61,24 @@ struct ButtonRect { // * buttonWidth — the button's fixed width. // * 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 -// readout). COUPLED TO drawTailFooter (bank_panel.cpp): the version -// readout uses `vrc.right -= 8` (8 px right margin). The button's -// right edge lands at footer.right - 84, i.e. 76 px left of the -// readout's right margin — enough clearance for the ~10-char label. -// If the version readout's inset changes in drawTailFooter, update -// this value to maintain clearance. +// readout). COUPLED TO drawFooter (bank_panel.cpp): the version readout +// uses an 8 px right margin. The button's right edge lands at +// footer.right - 84, i.e. 76 px left of the readout's right margin — +// enough clearance for the ~10-char label. ALSO COUPLED to +// FooterBarSpec::rightReserve (footer_bar.h): the L4 footer-left group +// (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 // 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 -// left edge (reserving room for the left-aligned tail label). If the -// button would encroach past this, computePruneButton yields an empty -// rect (button suppressed — see header placement contract). +// left edge (reserving room for the L4 footer-left group). If the button +// would encroach past this, computePruneButton yields an empty rect +// (button suppressed — see header placement contract). struct PruneButtonSpec { 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 minLeftInset = 120; }; diff --git a/tests/test_footer_bar.cpp b/tests/test_footer_bar.cpp new file mode 100644 index 0000000..ba8cb4d --- /dev/null +++ b/tests/test_footer_bar.cpp @@ -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 + +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; +} From f8fffabf1560b30351966fd36b83c97584a2d7d4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:04:48 -0400 Subject: [PATCH 2/2] fix(bank_panel): restore Re-capture + Cancel RT buttons to top toolbar --- src/bank_panel.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 33a6981..05b4a3f 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -724,10 +724,11 @@ struct ActionBarRow { }; // The TOP toolbar inventory: Capture (item / track / batch items / batch razor / RT) then -// Placement (insert / insert-conform). Capture scopes come from captureActionTable() -// (render_settings, pure); the rest are the registered M11/M6 commands. RECONCILED against the -// actually-REGISTERED commands — "resample-and-mute" / "null-test verify" are not registered -// commands and drag-out is a mouse gesture, so none are placed. Built once per draw/click. +// Placement (insert / insert-conform) then Maintenance (re-capture from source / cancel RT). +// Capture scopes come from captureActionTable() (render_settings, pure); the rest are the +// registered M11/M10/M8 commands. RECONCILED against the actually-REGISTERED commands — +// "resample-and-mute" / "null-test verify" are not registered commands and drag-out is a +// mouse gesture, so none are placed. Built once per draw/click. std::vector topBarRows() { std::vector rows; // Capture cluster — the primary gesture, leftmost. @@ -743,6 +744,10 @@ std::vector topBarRows() { // 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_CONFORM", "Insert Conform", ActionCluster::Placement}); + // Maintenance cluster — rarer upkeep: re-capture from source (M10) and cancel an + // in-flight realtime capture (M8). Capture-adjacent, so they live in the top toolbar. + rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", ActionCluster::Maintenance}); + rows.push_back({"CANCEL_REALTIME_CAPTURE", "Cancel RT", ActionCluster::Maintenance}); return rows; } @@ -769,8 +774,8 @@ std::vector bottomBarRows() { // 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. +// order follows each toolbar's fixed layout order (top: Capture, Placement, Maintenance; +// bottom: Tagging, Switching). std::vector actionBarClusters(const std::vector& rows) { int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0; for (const ActionBarRow& r : rows) {