From 54f37be0bb96b53c1aba72efafa63a43fa5c476a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:52:37 -0400 Subject: [PATCH] L2: task-grouped action bar + kit-drawn dock panel New pure action_bar module (clusters, keybinding sub-labels, overflow, hit-test) supersedes the flat M11 strip; bank_panel chrome/buttons/tabs/grid now draw through the L1 kit by role with hover. Expose draw_kit::toLice in header to fix drawThumbnail forward-refs. CTest 27/27. --- CMakeLists.txt | 22 +- src/action_bar.cpp | 167 +++++++++ src/action_bar.h | 155 ++++++++ src/bank_panel.cpp | 726 +++++++++++++++++++++++--------------- src/draw_kit.cpp | 7 +- src/draw_kit.h | 18 +- tests/test_action_bar.cpp | 325 +++++++++++++++++ 7 files changed, 1128 insertions(+), 292 deletions(-) create mode 100644 src/action_bar.cpp create mode 100644 src/action_bar.h create mode 100644 tests/test_action_bar.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b54657f..59273f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -355,6 +355,21 @@ target_include_directories(theme PUBLIC src) add_library(component_geometry STATIC src/component_geometry.cpp) target_include_directories(component_geometry PUBLIC src) +# --------------------------------------------------------------------------- +# 2o) Pure action_bar library — NO REAPER, NO SWELL, NO LICE. The Phase L (L2) +# dock-panel layout redesign core: the TASK-GROUPED action bar geometry that +# supersedes the flat M11 action_buttons strip for the panel's action inventory — +# clusters (capture / placement / maintenance) tile the bar at a fixed button +# width with intra/inter-cluster gaps, each button carrying a label + keybinding +# micro sub-rect, whole trailing buttons dropped (never clipped) on a narrow panel, +# and point -> flat action index hit-test. Split out so the layout + hit-test math +# is unit-tested outside the DAW; the bank_panel L1-kit draw + NamedCommandLookup/ +# Main_OnCommand dispatch + kbd_getTextFromCmd query are DAW-verified. Mirror of +# mode_switch / action_buttons / prune_button. +# --------------------------------------------------------------------------- +add_library(action_bar STATIC src/action_bar.cpp) +target_include_directories(action_bar PUBLIC src) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -465,6 +480,10 @@ add_executable(component_geometry_tests tests/test_component_geometry.cpp) target_link_libraries(component_geometry_tests PRIVATE component_geometry) add_test(NAME component_geometry_tests COMMAND component_geometry_tests) +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) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -508,8 +527,9 @@ add_library(reaper_reasampler MODULE src/bank_book.cpp src/owned_manifest.cpp src/drag_out_win.cpp + src/action_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) +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_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.cpp b/src/action_bar.cpp new file mode 100644 index 0000000..9ceaa1d --- /dev/null +++ b/src/action_bar.cpp @@ -0,0 +1,167 @@ +// action_bar — pure implementation. See action_bar.h. NO REAPER / SWELL / LICE / vendor. + +#include "action_bar.h" + +#include + +namespace reasampler { + +namespace { + +// The total button count across all clusters (empty clusters contribute nothing). +int totalButtons(const std::vector& clusters) { + int n = 0; + for (const ClusterSpec& c : clusters) + if (c.count > 0) n += c.count; + return n; +} + +// Fills a slot's label / binding sub-rects from its box per the spec. The binding is the +// bottom `bindingHeight` micro strip; the label is the remainder above it, both inset +// horizontally so text clears the button edge. A button shorter than minSplitHeight is not +// split: bindingBox stays empty and the label fills the interior (the shell draws only the +// label — graceful, no clipped micro row). +void fillTextRects(ActionBarSlot& s, const ActionBarSpec& spec) { + const int hpad = 4; // horizontal text inset inside the button + const int innerX = s.x + hpad; + const int innerW = s.width - 2 * hpad; + if (innerW <= 0) return; // too narrow for text; leave sub-rects empty + + if (s.height >= spec.minSplitHeight && spec.bindingHeight > 0 && + s.height - spec.bindingHeight > 0) { + const int bindH = spec.bindingHeight; + const int labelH = s.height - bindH; + s.labelX = innerX; s.labelY = s.y; s.labelW = innerW; s.labelH = labelH; + s.bindX = innerX; s.bindY = s.y + labelH; s.bindW = innerW; s.bindH = bindH; + } else { + // Too short to split — label fills the interior; no binding row. + s.labelX = innerX; s.labelY = s.y; s.labelW = innerW; s.labelH = s.height; + s.bindX = s.bindY = s.bindW = s.bindH = 0; + } +} + +// Tiles the first `visible` buttons into slots, cluster by cluster, left to right. This is the +// ONE placement routine; both computeBarSlots and hitTestActionBar drive it so draw and +// hit-test can never drift. `visible` is assumed already clamped to [0, total]. Returns the +// slots in ascending flat-index order. +std::vector tile(const ActionBarRect& bar, + const std::vector& clusters, + const ActionBarSpec& spec, int visible) { + std::vector slots; + if (visible <= 0) return slots; + slots.reserve(static_cast(visible)); + + const int top = bar.y + spec.verticalInset; + const int btnH = bar.height - 2 * spec.verticalInset; + if (btnH <= 0) return slots; + + int cursorX = bar.x + spec.sidePad; + int flatIndex = 0; // running flat action index across all clusters + int placed = 0; // buttons placed so far (stops at `visible`) + bool firstClusterEmitted = false; + + for (const ClusterSpec& c : clusters) { + if (c.count <= 0) continue; // skip empty clusters (no gap emitted) + if (placed >= visible) break; + + // Gap BEFORE this cluster (except the first non-empty one). + if (firstClusterEmitted) cursorX += spec.clusterGap; + firstClusterEmitted = true; + + for (int i = 0; i < c.count; ++i, ++flatIndex) { + if (placed >= visible) return slots; // overflow cut — stop cleanly + if (i > 0) cursorX += spec.buttonGap; // gap between buttons in the cluster + + ActionBarSlot s; + s.index = flatIndex; + s.cluster = c.cluster; + s.x = cursorX; + s.y = top; + s.width = spec.buttonWidth; + s.height = btnH; + fillTextRects(s, spec); + slots.push_back(s); + + cursorX += spec.buttonWidth; + ++placed; + } + } + return slots; +} + +// The rightmost pixel the first `visible` buttons would occupy (bar.x + sidePad based). Used by +// computeBarFit to test whether a candidate visible-count fits within the bar's usable width. +// Mirrors tile()'s advance math exactly (gaps included) so fit and layout agree. +int rightEdgeFor(const ActionBarRect& bar, const std::vector& clusters, + const ActionBarSpec& spec, int visible) { + if (visible <= 0) return bar.x + spec.sidePad; + int cursorX = bar.x + spec.sidePad; + int placed = 0; + bool firstClusterEmitted = false; + for (const ClusterSpec& c : clusters) { + if (c.count <= 0) continue; + if (placed >= visible) break; + if (firstClusterEmitted) cursorX += spec.clusterGap; + firstClusterEmitted = true; + for (int i = 0; i < c.count; ++i) { + if (placed >= visible) return cursorX; + if (i > 0) cursorX += spec.buttonGap; + cursorX += spec.buttonWidth; // this button's right edge + ++placed; + if (placed >= visible) return cursorX; + } + } + return cursorX; +} + +} // namespace + +BarFit computeBarFit(const ActionBarRect& bar, const std::vector& clusters, + const ActionBarSpec& spec) { + BarFit fit; + const int total = totalButtons(clusters); + if (total <= 0 || bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) { + fit.hiddenCount = total > 0 ? total : 0; + return fit; + } + + const int usableRight = bar.x + bar.width - spec.sidePad; + // Largest prefix of buttons whose right edge stays within the usable right bound. Buttons + // never shrink; trailing ones that do not fit are the overflow (dropped whole). + int visible = 0; + for (int cand = 1; cand <= total; ++cand) { + if (rightEdgeFor(bar, clusters, spec, cand) <= usableRight) + visible = cand; + else + break; + } + fit.visibleCount = visible; + fit.hiddenCount = total - visible; + return fit; +} + +std::vector computeBarSlots(const ActionBarRect& bar, + const std::vector& clusters, + const ActionBarSpec& spec) { + if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return {}; + const BarFit fit = computeBarFit(bar, clusters, spec); + return tile(bar, clusters, spec, fit.visibleCount); +} + +int hitTestActionBar(int px, int py, const ActionBarRect& bar, + const std::vector& clusters, const ActionBarSpec& spec) { + if (bar.height <= 0 || bar.width <= 0) return -1; + // Reject outside the bar band first (half-open bounds match the slots). + if (px < bar.x || px >= bar.x + bar.width || + py < bar.y || py >= bar.y + bar.height) + return -1; + + const std::vector slots = computeBarSlots(bar, clusters, spec); + for (const ActionBarSlot& s : slots) { + if (px >= s.x && px < s.x + s.width && py >= s.y && py < s.y + s.height) + return s.index; + } + return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss +} + +} // namespace reasampler diff --git a/src/action_bar.h b/src/action_bar.h new file mode 100644 index 0000000..5691bc6 --- /dev/null +++ b/src/action_bar.h @@ -0,0 +1,155 @@ +#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. +// +// 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 +// DAW-verified. What is NOT DAW-bound — how the clusters tile the bar, where each button and +// its two text sub-rects sit, and which button a click hits — lives HERE, unit-tested outside +// the DAW. Mirror of mode_switch / action_buttons / prune_button. +// +// NAME NOTE (brief §name-collision): ButtonRect / ButtonStripRect / ActionButtonRect / +// SegmentRect / CellRect / FooterRect / KitButtonBox are already owned in this namespace, so +// this module's types are ActionBarRect / ActionBarSlot / ActionCluster — grep-checked free +// before minting. They are a distinct concept (a task-grouped multi-cluster bar with text +// sub-rects) from the flat action_buttons strip, so the separate names are correct, not merely +// non-colliding. +// +// SCOPE: the destructive PRUNE button is NOT in this bar — it stays set-apart in the footer, +// warn-marked, owned by prune_button (L2 keeps prune deliberately away from the frequent +// action cluster). This module lays out only the non-destructive capture/placement/maintenance +// actions. +// +// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. + +#include + +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. +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 + Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions +}; + +// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the +// top-left corner; width/height are the bar extents. The panel reserves this as a fixed-height +// band (its own judgment where — above the tail footer, below the split body). +struct ActionBarRect { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + bool operator==(const ActionBarRect& o) const { + return x == o.x && y == o.y && width == o.width && height == o.height; + } +}; + +// One visible button's placement within the bar, top-left origin. `index` is the button's +// position in the caller's flat action list (the caller supplies actions in cluster order, so +// index also selects the action to fire on a hit). `cluster` is the task group it was laid out +// under (surfaced so a test can assert the grouping is structural, and the shell can tint a +// cluster). `box` is the whole button rect; `labelBox` and `bindingBox` split it into the +// action-name row (top) and the keybinding MICRO row (bottom) so the shell draws each with the +// matching kit font. Only VISIBLE buttons get a slot — a button that does not fit is omitted, +// never returned clipped, so every slot is fully drawable. +struct ActionBarSlot { + int index = 0; + ActionCluster cluster = ActionCluster::Capture; + int x = 0; + int y = 0; + int width = 0; + int height = 0; + // Text sub-rects (absolute, top-left origin), both inside `box`. bindingBox is the bottom + // micro strip; labelBox is the remainder above it. When the button is too short to split + // (height < a minimum), bindingBox is empty (width/height 0) and labelBox is the whole + // interior — the shell then draws only the label (graceful, no clipped micro row). + int labelX = 0, labelY = 0, labelW = 0, labelH = 0; + int bindX = 0, bindY = 0, bindW = 0, bindH = 0; + + bool bindingEmpty() const { return bindW <= 0 || bindH <= 0; } + + bool operator==(const ActionBarSlot& o) const { + return index == o.index && cluster == o.cluster && + x == o.x && y == o.y && width == o.width && height == o.height && + labelX == o.labelX && labelY == o.labelY && + labelW == o.labelW && labelH == o.labelH && + bindX == o.bindX && bindY == o.bindY && + bindW == o.bindW && bindH == o.bindH; + } +}; + +// 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. +struct ClusterSpec { + ActionCluster cluster = ActionCluster::Capture; + int count = 0; +}; + +// Layout inputs for the bar, in pixels. Defaults are the bank_panel action-bar metrics; the +// shell passes its own so draw and hit-test share ONE source of truth. +// * buttonWidth — each button's fixed width (buttons never render narrower; overflow drops +// whole trailing buttons instead of shrinking below this). +// * buttonGap — horizontal gap between buttons WITHIN a cluster. +// * clusterGap — horizontal gap between adjacent clusters (wider than buttonGap so the +// task grouping reads visually; the 8px-grid density decision). +// * sidePad — left/right inset from the bar edges to the first/last button. +// * verticalInset — top/bottom gap inside the bar (buttons read as raised, not full-bleed). +// * bindingHeight — height of the keybinding MICRO sub-row at the button's bottom. +// * minSplitHeight— a button shorter than this is not split (bindingBox empty; label fills). +struct ActionBarSpec { + int buttonWidth = 108; + int buttonGap = 4; + int clusterGap = 16; + int sidePad = 8; + int verticalInset = 3; + int bindingHeight = 11; + int minSplitHeight = 30; +}; + +// How many buttons (from the front, cluster by cluster) fit the bar at `spec.buttonWidth`. +// Split from slot tiling so the shell can size an overflow affordance / count without +// re-deriving it. Trailing buttons that do not fit are the overflow (dropped whole). A +// non-positive bar width, or a bar too narrow for even one button, yields 0. Clamps to +// [0, total-button-count]. +struct BarFit { + int visibleCount = 0; // buttons that fit (laid out), counted from the front + int hiddenCount = 0; // total - visibleCount (the overflow, dropped whole) +}; + +BarFit computeBarFit(const ActionBarRect& bar, const std::vector& clusters, + const ActionBarSpec& spec); + +// Lays out the VISIBLE buttons (per computeBarFit) left-to-right in cluster order: buttons +// pack at buttonWidth with buttonGap inside a cluster and clusterGap between clusters, starting +// at bar.x + sidePad. Each slot carries its flat action index, its cluster, its box, and the +// label / keybinding sub-rects. Empty clusters emit no gap. Returns exactly visibleCount slots +// in ascending index order. A degenerate bar (width/height <= 0), an empty cluster list, or a +// non-positive buttonWidth yields empty. +std::vector computeBarSlots(const ActionBarRect& bar, + const std::vector& clusters, + const ActionBarSpec& spec); + +// The flat action index the point (px, py) (SWELL/LICE top-left client coords) lands on, or -1 +// for a miss: outside the bar band, in an inter-button / inter-cluster gap, or past the last +// visible button (the narrow-panel overflow dead-zone — a harmless no-op the shell ignores). +// Half-open bounds [x, x+width) x [y, y+height) match computeBarSlots so no pixel is double- +// claimed and the hit maps to the button drawn there. Unlike an equal-tiled strip, the bar has +// real gaps, so a gap point is a clean miss (not the nearest button). +int hitTestActionBar(int px, int py, const ActionBarRect& bar, + const std::vector& clusters, const ActionBarSpec& spec); + +} // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 8c0d875..83d53f8 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -46,7 +46,8 @@ #include #include -#include "action_buttons.h" // pure button-strip layout + label format (M11) +#include "action_bar.h" // pure TASK-GROUPED action-bar layout + hit-test (L2) +#include "action_buttons.h" // pure label format (formatButtonLabel) — reused by the L2 bar (M11) #include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) #include "drag_out.h" // pure gesture-boundary decision + path-list assembly (M11) #include "drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) @@ -132,70 +133,35 @@ namespace { namespace fs = std::filesystem; -// --- Layout / palette constants ---------------------------------------------- +// --- Layout constants --------------------------------------------------------- +// +// L2: every panel COLOR now comes from the pure `theme` module by ROLE (drawn through the L1 +// kit — fillSurface / drawButton / kit text). The former flat LICE_RGBA / RGB palette blocks +// are retired; only the pixel LAYOUT metrics (band heights, grid/tab specs, insets) live here. const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) -const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); -const LICE_pixel kColCellBg = LICE_RGBA(44, 44, 48, 255); -const LICE_pixel kColCellBorder = LICE_RGBA(70, 70, 76, 255); -const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255); -const LICE_pixel kColMidline = LICE_RGBA(60, 60, 66, 255); -const LICE_pixel kColSelBg = LICE_RGBA(38, 66, 58, 255); -const LICE_pixel kColSelBorder = LICE_RGBA(120, 200, 160, 255); -const LICE_pixel kColFocusBorder = LICE_RGBA(210, 230, 220, 255); - // --- Mode-switch header (D5) -------------------------------------------------- constexpr int kHeaderHeight = 30; -const LICE_pixel kColHeaderBg = LICE_RGBA(20, 20, 22, 255); -const LICE_pixel kColSegBg = LICE_RGBA(44, 44, 48, 255); -const LICE_pixel kColSegActiveBg = LICE_RGBA(58, 96, 84, 255); -const LICE_pixel kColSegBorder = LICE_RGBA(70, 70, 76, 255); - -const COLORREF kRgbSegText = RGB(170, 170, 176); -const COLORREF kRgbSegActiveText = RGB(220, 235, 228); - // --- Tail-mode footer (T1 exposure) ------------------------------------------- constexpr int kFooterHeight = 26; -const LICE_pixel kColFooterBg = LICE_RGBA(20, 20, 22, 255); -const LICE_pixel kColFooterBorder = LICE_RGBA(70, 70, 76, 255); -const COLORREF kRgbFooterText = RGB(190, 205, 198); -// Dimmer than the tail label — the version/channel readout is passive identification, -// not an interactive control, so it recedes visually (V3 unobtrusive placement). -const COLORREF kRgbFooterVersion = RGB(120, 128, 124); - -// Prune button (R3): a raised control in the footer that fires the "Prune bank folder" -// action. A muted warm tone so it reads as a distinct-but-not-alarming affordance (the -// destructive confirm lives behind it, not on the button itself). -const LICE_pixel kColPruneBtnBg = LICE_RGBA(62, 46, 42, 255); -const LICE_pixel kColPruneBtnBorder = LICE_RGBA(96, 72, 66, 255); -const COLORREF kRgbPruneBtnText = RGB(210, 188, 180); - -// --- Action-trigger button strip (M11) ---------------------------------------- -// A fixed-height band of LICE-drawn buttons directly ABOVE the tail footer (below the -// split body). Each button fires a registered action via the command-id contract and -// shows its current key binding. The strip's layout / hit-test / label format is the -// pure action_buttons module; only draw + dispatch + the SDK binding query live here. +// --- 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; -constexpr int kButtonMinWidth = 96; // buttons never draw narrower (overflow hides excess) - -const LICE_pixel kColBtnStripBg = LICE_RGBA(20, 20, 22, 255); -const LICE_pixel kColBtnStripBorder = LICE_RGBA(70, 70, 76, 255); -const LICE_pixel kColActionBtnBg = LICE_RGBA(48, 48, 52, 255); -const LICE_pixel kColActionBtnBorder = LICE_RGBA(90, 90, 96, 255); -const COLORREF kRgbActionBtnText = RGB(210, 215, 220); // --- Vertical split + region headers + tab strip (Phase B4) ------------------- // // The client area, top to bottom: mode-switch header (kHeaderHeight) | split body | -// 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. +// 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. 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 @@ -205,27 +171,6 @@ constexpr int kCreateBtnWidth = 22; // the "+" create-bank button // Tab strip metrics (the pure tab_strip owns the math; these are its inputs). const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; -const LICE_pixel kColRegionHeaderBg = LICE_RGBA(24, 24, 26, 255); -const LICE_pixel kColRegionBorder = LICE_RGBA(70, 70, 76, 255); -const LICE_pixel kColDivider = LICE_RGBA(12, 12, 14, 255); -const LICE_pixel kColBtnBg = LICE_RGBA(48, 48, 52, 255); -const LICE_pixel kColBtnBorder = LICE_RGBA(90, 90, 96, 255); - -const LICE_pixel kColTabBg = LICE_RGBA(40, 40, 44, 255); -const LICE_pixel kColTabShownBg = LICE_RGBA(58, 58, 64, 255); // the shown tab (browsed) -const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255); // the ACTIVE bank (capture target) -const LICE_pixel kColTabBorder = LICE_RGBA(70, 70, 76, 255); -const LICE_pixel kColTabActiveBorder= LICE_RGBA(150, 230, 190, 255);// active-tab accent -const LICE_pixel kColChevronBg = LICE_RGBA(32, 32, 36, 255); -// Drop-target highlight during a drag (unmistakable accent over the destination). -const LICE_pixel kColDropTarget = LICE_RGBA(90, 150, 120, 255); - -const COLORREF kRgbRegionTitle = RGB(200, 205, 210); -const COLORREF kRgbActiveReadout = RGB(150, 230, 190); // "Active: …" accent -const COLORREF kRgbTabText = RGB(200, 200, 205); -const COLORREF kRgbTabActiveText = RGB(230, 245, 238); -const COLORREF kRgbBtnText = RGB(210, 215, 220); - // --- Panel state -------------------------------------------------------------- struct CachedThumbnail { @@ -243,6 +188,42 @@ enum class Region { Pool, Banks }; // always shownBankId. enum class DropKind { None, PoolRegion, Tab, BanksRegion }; +// --- Hover model (Phase L, L2) ------------------------------------------------ +// +// The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its +// hover state on that element only (the "hover on every interactive element" + "sub-frame +// feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep +// of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None +// rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe. +// `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) + 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) +}; + +struct Hover { + HoverKind kind = HoverKind::None; + int index = -1; + + bool operator==(const Hover& o) const { return kind == o.kind && index == o.index; } + bool operator!=(const Hover& o) const { return !(*this == o); } +}; + +// The kit interaction state for an interactive element: Hover when this (kind,index) is the +// live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g. +// an active tab draws Active regardless of hover); this is the base rest/hover resolver. +InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) { + return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover + : InteractionState::Rest; +} + struct PanelState { ReaSamplerSession* session = nullptr; @@ -261,6 +242,11 @@ struct PanelState { int selItemCount = 0; Region focusedRegion = Region::Pool; + // --- Hover (Phase L, L2) -------------------------------------------------- + // The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its + // hover state). Repaint fires only when this changes (sub-frame, no per-move jank). + Hover hovered; + // --- Vertical-split state ------------------------------------------------- BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; @@ -437,21 +423,34 @@ const Envelope& thumbnailFor(const Sample& sample, int width, // --- Drawing: thumbnails (unchanged from M5) ---------------------------------- void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, - bool selected, bool focused) { - const LICE_pixel bg = selected ? kColSelBg : kColCellBg; - LICE_pixel border = selected ? kColSelBorder : kColCellBorder; - if (focused) border = kColFocusBorder; + bool selected, bool focused, bool hovered) { + // Cell surface through the kit: Active (accent) when selected, else hover-or-rest bg/cell. + // The grid is the centerpiece (bones preserved) — the surface picks up the L2 palette + + // micro-gradient while the waveform plot below stays the panel's own draw. + const KitBox cell{rect.x, rect.y, rect.width, rect.height}; + const InteractionState state = selected ? InteractionState::Active + : (hovered ? InteractionState::Hover + : InteractionState::Rest); + fillSurface(bmp, cell, Role::BgCell, state); - LICE_FillRect(bmp, rect.x, rect.y, rect.width, rect.height, bg, 1.0f, 0); - LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, border, 1.0f, 0); - if (focused) - LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, - border, 1.0f, 0); + // Border: accent when selected, else hairline. A focus ring is a distinct text/primary + // double-line (the kit's focus convention) so focus reads even on a selected cell. + const KitColor border = selected ? roleColor(Role::Accent) : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0); + if (focused) { + const LICE_pixel ring = toLice(roleColor(Role::TextPrimary)); + LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0); + } + + // Waveform plot (peaks invariant: min<=max). The wave uses the accent role except on a + // selected cell (whose fill is already the accent) — there it draws in bg/base for contrast. + const LICE_pixel midCol = toLice(roleColor(Role::LineHairline)); + const LICE_pixel waveCol = + toLice(selected ? roleColor(Role::BgBase) : roleColor(Role::Accent)); if (env.empty()) { const int midY = rect.y + rect.height / 2; - LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, - kColMidline, 1.0f, 0, false); + LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false); return; } @@ -464,8 +463,7 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, const int midY = bandTop + bandH / 2; const double halfSpan = (bandH / 2) - 2; - LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, - kColMidline, 1.0f, 0, false); + LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false); const int nbins = static_cast(bins.size()); if (nbins <= 0) continue; @@ -479,43 +477,36 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, int yMin = midY - static_cast(compressAmplitudeForDisplay(bins[i].min) * halfSpan); // min -> down if (yMax < bandTop) yMax = bandTop; if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1; - LICE_Line(bmp, x, yMin, x, yMax, kColWaveform, 1.0f, 0, false); + LICE_Line(bmp, x, yMin, x, yMax, waveCol, 1.0f, 0, false); } } } -// --- GDI-text retirement (Phase L, L1) ---------------------------------------- +// --- Kit draw adapters (Phase L) ---------------------------------------------- // -// All panel text now draws through the kit's cached AA font (draw_kit::text), NOT raw GDI -// DrawText — the single biggest "temple os -> modern" lever. These thin adapters bridge the -// panel's existing RECT + COLORREF + DT_* call sites to the kit's KitBox + KitColor + Align -// so the retirement is mechanical and preserves each site's current color/alignment (L1 is -// the text-engine swap; the palette re-role is L2). The kit owns the font lifecycle +// All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText +// (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces +// via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based +// geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for +// the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle // (kitFontsInit/Shutdown, wired at panel open/close below). KitBox toKitBox(const RECT& r) { return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top}; } -KitColor toKitColor(COLORREF c) { - return KitColor{static_cast(GetRValue(c)), - static_cast(GetGValue(c)), - static_cast(GetBValue(c)), 255}; -} +// KitColor -> LICE_pixel at the panel's own boundary. draw_kit::toLice is now declared in +// draw_kit.h and used for the four drawThumbnail sites above. This local alias is kept for +// the remaining raw LICE_DrawRect/LICE_Line borders the panel draws over kit surfaces +// (dividers, drop-target highlights, segment/tab hairlines) so those pick up palette roles. +LICE_pixel toLicePixel(const KitColor& c) { return LICE_RGBA(c.r, c.g, c.b, c.a); } -// Maps the panel's DT_* horizontal flag to the kit's Align (the only three the panel uses). -Align toKitAlign(UINT fmt) { - if (fmt & DT_CENTER) return Align::Center; - if (fmt & DT_RIGHT) return Align::Right; - return Align::Left; -} - -// Draws a single-line label into a rect through the kit's cached AA font (was GDI DrawText). -// `fmt` carries only the horizontal alignment (the kit always v-centers + single-lines + -// end-ellipsis, matching the retired DrawText flags). Font::Label is the panel's body face. -void drawCenteredText(LICE_IBitmap* bmp, const RECT& rc, const char* txt, - COLORREF color, UINT fmt) { - text(bmp, toKitBox(rc), txt, Font::Label, toKitColor(color), toKitAlign(fmt)); +// L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit +// Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for +// region headings). Takes a KitBox directly (the pure geometry the L2 modules return). +void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt, + Font font, Role role, Align align) { + text(bmp, box, txt, font, role, align); } // --- Mode-switch header (D5, unchanged) --------------------------------------- @@ -533,7 +524,8 @@ void drawModeSwitch(LICE_IBitmap* bmp, int w) { const std::vector& modes = view.modes().all(); const int n = static_cast(modes.size()); - LICE_FillRect(bmp, 0, 0, w, kHeaderHeight, kColHeaderBg, 1.0f, 0); + // 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); @@ -547,13 +539,18 @@ void drawModeSwitch(LICE_IBitmap* bmp, int w) { const Mode& mode = modes[static_cast(i)]; const bool active = mode.id == activeId; - LICE_FillRect(bmp, s.x, s.y, s.width, s.height, - active ? kColSegActiveBg : kColSegBg, 1.0f, 0); - LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, kColSegBorder, 1.0f, 0); + // 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, + toLicePixel(roleColor(Role::LineHairline)), 1.0f, 0); - RECT rc{s.x, s.y, s.x + s.width, s.y + s.height}; - drawCenteredText(bmp, rc, mode.displayName.c_str(), - active ? kRgbSegActiveText : kRgbSegText, DT_CENTER); + 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); } } @@ -582,14 +579,18 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return; - LICE_FillRect(bmp, f.left, f.top, w, kFooterHeight, kColFooterBg, 1.0f, 0); - LICE_Line(bmp, f.left, f.top, f.right, f.top, kColFooterBorder, 1.0f, 0, false); + // 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, + toLicePixel(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()); - RECT rc = f; - rc.left += 8; - drawCenteredText(bmp, rc, label.c_str(), kRgbFooterText, DT_LEFT); + 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 @@ -600,10 +601,9 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { // 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. - RECT vrc = f; - vrc.right -= 8; // COUPLED: PruneButtonSpec::rightInset in prune_button.h is 84 - drawCenteredText(bmp, vrc, reasampler::appVersion().c_str(), - kRgbFooterVersion, DT_RIGHT); + // 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 @@ -626,11 +626,11 @@ void drawPruneButton(LICE_IBitmap* bmp, int w, int h) { const ButtonRect b = pruneButtonRectFor(w, h); if (b.empty()) return; - LICE_FillRect(bmp, b.x, b.y, b.width, b.height, kColPruneBtnBg, 1.0f, 0); - LICE_DrawRect(bmp, b.x, b.y, b.width, b.height, kColPruneBtnBorder, 1.0f, 0); - - RECT rc{b.x, b.y, b.x + b.width, b.y + b.height}; - drawCenteredText(bmp, rc, "Prune", kRgbPruneBtnText, DT_CENTER); + // 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); } // True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. @@ -657,139 +657,212 @@ void markTailDirty() { if (proj) MarkProjectDirty(proj); } -// === Action-trigger button strip (M11) — one bounded region =================== +// === Task-grouped action bar (Phase L, L2) ==================================== // -// A row of clickable buttons that FIRE the registered capture / insert / provenance -// actions THROUGH the existing command-id contract, never re-implementing capture. Each -// button resolves its command id at RUNTIME from the composed named-command string -// (NamedCommandLookup on "_" + channelCommandId(suffix) — the same id minted at -// registration in main.cpp), so it is channel-correct on stable and beta automatically -// and adds NO second registration. Labels surface the current key binding via -// kbd_getTextFromCmd (main section). The layout / hit-test / label-format math is the -// pure action_buttons module; only the draw + SDK query + dispatch live here. +// 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). +// +// 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. -// One button row: the channel-AGNOSTIC command-id suffix (composed with the channel -// prefix at runtime — never a hardcoded numeric id) and the terse on-button label. -struct ActionButtonRow { - std::string suffix; // e.g. "CAPTURE_ITEM" — composed via channelCommandId at fire time - std::string shortLabel; // e.g. "Capture Item" — the button's action-name text +// 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). +struct ActionBarRow { + std::string suffix; + std::string shortLabel; + ActionCluster cluster = ActionCluster::Capture; }; -// The button row set, TABLE-DRIVEN so future actions appear with minimal wiring: the two -// capture scopes come straight from captureActionTable() (render_settings, pure), then the -// known singleton actions main.cpp registers (realtime capture/cancel, insert native/ -// conform, re-capture from source). Order is capture-first (the primary gesture), then -// realtime, then placement, then re-capture. Built once per draw/click — cheap (a handful -// of small strings) and always in step with the registered families. -std::vector actionButtonRows() { - std::vector rows; +// 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() { + std::vector rows; + // Capture cluster — the primary gesture, leftmost. for (const CaptureActionDef& def : captureActionTable()) { - // descriptionPhrase is the long Actions-list phrase ("capture selected track(s)"); - // the button wants a terse label, so map the two known scopes by suffix. std::string label = def.commandSuffix; if (label == "CAPTURE_ITEM") label = "Capture Item"; else if (label == "CAPTURE_TRACK") label = "Capture Track"; - rows.push_back({def.commandSuffix, label}); + rows.push_back({def.commandSuffix, label, ActionCluster::Capture}); } - // Singleton actions (FOREVER-STABLE suffixes, mirrored from main.cpp's registration). - rows.push_back({"CAPTURE_TRACK_REALTIME", "Capture RT"}); - rows.push_back({"CANCEL_REALTIME_CAPTURE", "Cancel RT"}); - rows.push_back({"INSERT_SELECTED", "Insert"}); - rows.push_back({"INSERT_SELECTED_CONFORM", "Insert (conform)"}); - rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture"}); + rows.push_back({"CAPTURE_BATCH_ITEMS", "Batch Items", ActionCluster::Capture}); + rows.push_back({"CAPTURE_TRACK_REALTIME", "Capture RT", ActionCluster::Capture}); + // Placement cluster. + 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 button strip band: a fixed-height strip directly above the tail footer. Empty -// (degenerate) when the client is too short to host it above the footer. -ButtonStripRect actionButtonStrip(int w, int h) { - ButtonStripRect s; +// 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(). +std::vector actionBarClusters(const std::vector& rows) { + int nCap = 0, nPlace = 0, nMaint = 0; + for (const ActionBarRow& r : rows) { + if (r.cluster == ActionCluster::Capture) ++nCap; + else if (r.cluster == ActionCluster::Placement) ++nPlace; + else ++nMaint; + } + return { + {ActionCluster::Capture, nCap}, + {ActionCluster::Placement, nPlace}, + {ActionCluster::Maintenance, nMaint}, + }; +} + +// The action-bar layout spec (the panel's 8px-grid density decision). One source of truth +// shared by draw and hit-test. +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 +// body). Degenerate (height 0) when the client is too short to host it above the footer. +ActionBarRect actionBarRect(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 strip below the mode-switch header; if the client is too short, collapse it. + // 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; } return s; } -// Resolves a row's composed named command to its runtime command id (0 if the action is -// not registered — e.g. a beta binary the row's family has not registered). The named- -// command lookup string is "_" + the channel-qualified id (REAPER's convention for -// extension-registered ids, per the SDK header's NamedCommandLookup note). -int resolveActionCommandId(const ActionButtonRow& row) { +// Resolves a row's composed named command to its runtime command id (0 if not registered). +// The named-command lookup string is "_" + the channel-qualified id (REAPER's convention). +int resolveBarCommandId(const ActionBarRow& row) { if (!NamedCommandLookup) return 0; const std::string named = "_" + channelCommandId(row.suffix); return NamedCommandLookup(named.c_str()); } -// The button label for a row: action name + its current key binding, or the unbound -// marker. Queries kbd_getTextFromCmd in the MAIN section (SectionFromUniqueID(0)); a null -// / empty / blank return degrades to the unbound marker in the pure formatter. When the -// action is not registered (cmd == 0) the binding is treated as unbound. -std::string actionButtonLabel(const ActionButtonRow& row, int cmd) { - std::string binding; +// The current key binding string for a command in the MAIN section, or "" (unbound / not +// registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)). +std::string barBindingText(int cmd) { if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) { - const char* text = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); - if (text) binding = text; + const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); + if (t) return std::string(t); } - return formatButtonLabel(row.shortLabel, binding); + return {}; } -// Draws the button strip: a filled band, a top divider, and each visible button with its -// binding label. Overflow (a narrow panel) HIDES the excess buttons — the pure layout -// returns only the buttons that fit at kButtonMinWidth, so nothing is drawn clipped. -void drawActionButtons(LICE_IBitmap* bmp, int w, int h) { - const ButtonStripRect strip = actionButtonStrip(w, h); - if (strip.height <= 0 || strip.width <= 0) 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); + if (bar.height <= 0 || bar.width <= 0) return; - LICE_FillRect(bmp, strip.x, strip.y, strip.width, strip.height, kColBtnStripBg, 1.0f, 0); - LICE_Line(bmp, strip.x, strip.y, strip.x + strip.width, strip.y, - kColBtnStripBorder, 1.0f, 0, false); + // 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, + LICE_RGBA(0, 0, 0, 255), 0.5f, 0, false); - const std::vector rows = actionButtonRows(); - const int n = static_cast(rows.size()); - const std::vector rects = - computeButtonRects(strip, n, kButtonMinWidth); + const std::vector rows = actionBarRows(); + const std::vector clusters = actionBarClusters(rows); + const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); - for (const ActionButtonRect& r : rects) { - LICE_FillRect(bmp, r.x + 1, r.y + 2, r.width - 2, r.height - 4, - kColActionBtnBg, 1.0f, 0); - LICE_DrawRect(bmp, r.x + 1, r.y + 2, r.width - 2, r.height - 4, - kColActionBtnBorder, 1.0f, 0); - const ActionButtonRow& row = rows[static_cast(r.index)]; - const int cmd = resolveActionCommandId(row); - const std::string label = actionButtonLabel(row, cmd); - RECT rc{r.x + 4, r.y, r.x + r.width - 4, r.y + r.height}; - drawCenteredText(bmp, rc, label.c_str(), kRgbActionBtnText, DT_CENTER); + for (const ActionBarSlot& s : slots) { + if (s.index < 0 || s.index >= static_cast(rows.size())) continue; + const ActionBarRow& row = rows[static_cast(s.index)]; + const int cmd = resolveBarCommandId(row); + + // State: Disabled when the action is not registered on this channel; else Hover when + // 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) + state = InteractionState::Hover; + + // The button surface (drawButton draws the micro-gradient + rounded border + honors + // the state). The label is drawn separately below so the binding sub-row can use the + // Micro font, so pass no label to drawButton. + const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}}; + drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); + + const Role textRole = + (state == InteractionState::Disabled) ? Role::TextDim : Role::TextPrimary; + const KitBox labelBox{s.labelX, s.labelY, s.labelW, s.labelH}; + kitText(bmp, labelBox, row.shortLabel.c_str(), Font::Label, textRole, Align::Center); + + if (!s.bindingEmpty()) { + // The keybinding help sub-label, dim + Micro. formatButtonLabel's blank/unbound + // collapse is reused so an unbound action reads "(unbound)" cleanly; here we want + // just the binding token (name is already on the label row), so format the binding + // 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}; + kitText(bmp, bindBox, shown.c_str(), Font::Micro, Role::TextDim, Align::Center); + } } } -// Routes a click in the button strip to the hit button's action, fired through the -// command-id contract (Main_OnCommand with the runtime-resolved id — REAPER runs the SAME -// action a keybinding or the Actions list would). Returns true iff the click was inside -// the strip (handled or a harmless overflow-dead-zone / unregistered no-op), so the caller -// stops before grid handling. A cmd of 0 (action not registered) is a silent no-op. -bool handleActionButtonClick(int x, int y) { +// 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); + 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 ButtonStripRect strip = actionButtonStrip(w, h); - if (strip.height <= 0) return false; + const ActionBarRect bar = actionBarRect(w, h); + if (bar.height <= 0) return false; - const std::vector rows = actionButtonRows(); - const int n = static_cast(rows.size()); - const int hit = hitTestButton(x, y, strip, n, kButtonMinWidth); + const int hit = actionBarHit(x, y); if (hit < 0) { - // Inside the strip band but not on a visible button (overflow dead-zone): claim - // the click so it never falls through to the grid. Outside the band: not ours. - return y >= strip.y && y < strip.y + strip.height && - x >= strip.x && x < strip.x + strip.width; + // 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 int cmd = resolveActionCommandId(rows[static_cast(hit)]); + const std::vector rows = actionBarRows(); + const int cmd = resolveBarCommandId(rows[static_cast(hit)]); if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); return true; } @@ -805,11 +878,11 @@ RECT splitBody(int w, int h) { rc.left = 0; rc.right = w; rc.top = kHeaderHeight; - // The body ends at the action-button strip (M11), which itself sits above the tail - // footer. When the strip collapses on a short client, actionButtonStrip returns its - // y at the footer top, so the body still ends at the footer edge. - const ButtonStripRect strip = actionButtonStrip(w, h); - rc.bottom = strip.y; + // 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.bottom = bar.y; if (rc.bottom < rc.top) rc.bottom = rc.top; return rc; } @@ -921,7 +994,7 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, if (grid.bottom <= grid.top) return; if (!index || index->empty()) { - drawCenteredText(bmp, grid, emptyMsg.c_str(), RGB(150, 150, 156), DT_CENTER); + kitText(bmp, toKitBox(grid), emptyMsg.c_str(), Font::Label, Role::TextDim, Align::Center); return; } @@ -935,7 +1008,10 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, const bool selected = selectionOwner && g_panel.selection.contains(idx); const bool focused = selectionOwner && g_panel.selection.focus == idx; const Envelope& env = thumbnailFor(samples[i], binWidth, projectDir); - drawThumbnail(bmp, rect, env, selected, focused); + // Grid-cell hover is intentionally not tracked: the cell already carries selection + + // focus chrome (the centerpiece's "bones"); a third transient hover state on every + // cell would add repaint churn + visual noise. Hover lights the chrome/buttons/tabs. + drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false); } } @@ -943,40 +1019,40 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, const std::string& activeName, bool poolBtnIsPool) { const RECT hdr = regionHeaderRect(region); - LICE_FillRect(bmp, hdr.left, hdr.top, hdr.right - hdr.left, - hdr.bottom - hdr.top, kColRegionHeaderBg, 1.0f, 0); + // Region header band (kit bg/panel — a raised region title bar). A hairline underline. + fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top}, + Role::BgPanel, InteractionState::Rest); LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, - kColRegionBorder, 1.0f, 0, false); + toLicePixel(roleColor(Role::LineHairline)), 1.0f, 0, false); - // Title, left. + // Title, left (Font::Title — a region heading). RECT titleRc = hdr; titleRc.left += 8; titleRc.right = titleRc.left + 120; - drawCenteredText(bmp, titleRc, title, kRgbRegionTitle, DT_LEFT); + kitText(bmp, toKitBox(titleRc), title, Font::Title, Role::TextPrimary, Align::Left); - // Active-bank readout, centered — the UNMISTAKABLE indicator (settled B4 - // constraint). It names the active/capture-target bank in an accent color in - // BOTH region headers, so the active bank is legible even when it is not the - // shown tab and even when it is the pool (no tab exists for it). + // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the ACCENT + // role in BOTH region headers so the active/capture-target bank is legible even when it is + // not the shown tab and even when it is the pool. Accent = "where the punch lives" (DS-2). const std::string readout = "Active: " + activeName; RECT actRc = hdr; actRc.left = titleRc.right + 6; actRc.right = createBtnRect(region).left - 6; if (actRc.right > actRc.left) - drawCenteredText(bmp, actRc, readout.c_str(), kRgbActiveReadout, DT_LEFT); + kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::Accent, Align::Left); - // Full-height toggle button: an arrow glyph. In split it means "maximize this - // region"; when this region is already full it means "restore the split". + // Full-height toggle button: an arrow glyph. In split it means "maximize this region"; + // when this region is already full it means "restore the split". Kit drawButton + hover. const RECT btn = fullHtBtnRect(region); - LICE_FillRect(bmp, btn.left, btn.top, btn.right - btn.left, - btn.bottom - btn.top, kColBtnBg, 1.0f, 0); - LICE_DrawRect(bmp, btn.left, btn.top, btn.right - btn.left, - btn.bottom - btn.top, kColBtnBorder, 1.0f, 0); const bool thisFull = poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly) : (g_panel.fullHeight == BankPanelFullHeight::BanksOnly); - drawCenteredText(bmp, btn, thisFull ? "[v]" : "[^]", // collapse / expand - kRgbBtnText, DT_CENTER); + const HoverKind hk = poolBtnIsPool ? HoverKind::FullHtPool : HoverKind::FullHtBanks; + const InteractionState state = + thisFull ? InteractionState::Active : hoverState(g_panel.hovered, hk, -1); + const KitButtonBox box{KitBox{btn.left, btn.top, btn.right - btn.left, + btn.bottom - btn.top}}; + drawButton(bmp, box, thisFull ? "v" : "^", state, /*warn=*/false); } // Draws the named-banks tab strip: one tab per named bank (ordinal order), the SHOWN @@ -986,15 +1062,16 @@ void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { const TabStripRect strip = banksTabStripRect(region); if (strip.height <= 0) return; - LICE_FillRect(bmp, strip.x, strip.y, strip.width, strip.height, - kColRegionHeaderBg, 1.0f, 0); + // Tab strip band (kit bg/base — recessed relative to the region header above it). + fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height}, + Role::BgBase, InteractionState::Rest); const std::vector tabs = namedBanks(); const int n = static_cast(tabs.size()); if (n == 0) { - RECT r{strip.x + 8, strip.y, strip.x + strip.width, strip.y + strip.height}; - drawCenteredText(bmp, r, "No named banks -- click + to create one.", - RGB(140, 140, 146), DT_LEFT); + kitText(bmp, KitBox{strip.x + 8, strip.y, strip.width - 8, strip.height}, + "No named banks -- click + to create one.", + Font::Label, Role::TextDim, Align::Left); return; } @@ -1003,16 +1080,13 @@ void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { // Chevrons (drawn first so tabs sit above their inner edges). if (layout.overflow) { - LICE_FillRect(bmp, strip.x, strip.y, kTabSpec.chevronWidth, strip.height, - kColChevronBg, 1.0f, 0); - LICE_FillRect(bmp, strip.x + strip.width - kTabSpec.chevronWidth, strip.y, - kTabSpec.chevronWidth, strip.height, kColChevronBg, 1.0f, 0); - RECT lc{strip.x, strip.y, strip.x + kTabSpec.chevronWidth, - strip.y + strip.height}; - RECT rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y, - strip.x + strip.width, strip.y + strip.height}; - drawCenteredText(bmp, lc, "<", kRgbTabText, DT_CENTER); - drawCenteredText(bmp, rc, ">", kRgbTabText, DT_CENTER); + const KitBox lc{strip.x, strip.y, kTabSpec.chevronWidth, strip.height}; + const KitBox rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y, + kTabSpec.chevronWidth, strip.height}; + fillSurface(bmp, lc, Role::BgCell, InteractionState::Rest); + fillSurface(bmp, rc, Role::BgCell, InteractionState::Rest); + kitText(bmp, lc, "<", Font::Label, Role::TextPrimary, Align::Center); + kitText(bmp, rc, ">", Font::Label, Role::TextPrimary, Align::Center); } const std::string activeId = book() ? book()->activeBankId() : std::string(); @@ -1025,22 +1099,32 @@ void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { const bool dropHere = g_panel.dragging && g_panel.dropKind == DropKind::Tab && g_panel.dropBankId == bk->id; + const bool hovered = g_panel.hovered.kind == HoverKind::Tab && + g_panel.hovered.index == tr.index; - LICE_pixel bg = shown ? kColTabShownBg : kColTabBg; - if (active) bg = kColTabActiveBg; - if (dropHere) bg = kColDropTarget; - LICE_FillRect(bmp, tr.x, tr.y, tr.width, tr.height, bg, 1.0f, 0); - // The active bank's tab gets a bright accent border (unmistakable), distinct - // from the shown tab's fill highlight — active ≠ shown, made visible. - LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, - active ? kColTabActiveBorder : kColTabBorder, 1.0f, 0); + // Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag + // drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit); + // else hover-or-rest bg/cell. + const KitBox tb{tr.x, tr.y, tr.width, tr.height}; + InteractionState state = InteractionState::Rest; + if (active) state = InteractionState::Active; + else if (dropHere) state = InteractionState::Dragging; + else if (shown) state = InteractionState::Pressed; + else if (hovered) state = InteractionState::Hover; + fillSurface(bmp, tb, Role::BgCell, state); + + // The active bank's tab gets a bright accent border (unmistakable), distinct from the + // shown tab's fill — active != shown, made visible (kit accent role). + const KitColor border = active ? roleColor(Role::Accent) : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, toLicePixel(border), 1.0f, 0); if (active) LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2, - kColTabActiveBorder, 1.0f, 0); + toLicePixel(border), 1.0f, 0); - RECT lr{tr.x + 4, tr.y, tr.x + tr.width - 4, tr.y + tr.height}; - drawCenteredText(bmp, lr, bk->displayName.c_str(), - active ? kRgbTabActiveText : kRgbTabText, DT_CENTER); + // Label: bg/base on the accent-active fill for contrast, else text/primary. + const Role trole = active ? Role::BgBase : Role::TextPrimary; + kitText(bmp, KitBox{tr.x + 4, tr.y, tr.width - 8, tr.height}, + bk->displayName.c_str(), Font::Label, trole, Align::Center); } } @@ -1062,7 +1146,7 @@ void paintPanel(HWND hwnd, HDC hdc) { if (w <= 0 || h <= 0) return; LICE_SysBitmap bmp(w, h); - LICE_Clear(&bmp, kColBackground); + LICE_Clear(&bmp, toLicePixel(roleColor(Role::BgBase))); const std::string projectDir = currentProjectDir(); const std::string activeName = activeBankName(); @@ -1079,7 +1163,7 @@ void paintPanel(HWND hwnd, HDC hdc) { const RECT grid = regionGridRect(region, false); LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, grid.right - grid.left - 2, grid.bottom - grid.top - 2, - kColDropTarget, 1.0f, 0); + toLicePixel(roleColor(Role::AccentHot)), 1.0f, 0); } } @@ -1087,20 +1171,21 @@ void paintPanel(HWND hwnd, HDC hdc) { if (poolShown() && banksShown()) { const RECT body = splitBody(w, h); const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; - LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight, kColDivider, 1.0f, 0); + LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight, + toLicePixel(roleColor(Role::BgBase)), 1.0f, 0); } // Named-banks region (bottom). if (banksShown()) { const RECT region = banksRegionRect(w, h); drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false); - // "+" create button (drawn as part of the banks header). + // "+" create button (drawn as part of the banks header) — kit drawButton + hover. const RECT cbtn = createBtnRect(region); - LICE_FillRect(&bmp, cbtn.left, cbtn.top, cbtn.right - cbtn.left, - cbtn.bottom - cbtn.top, kColBtnBg, 1.0f, 0); - LICE_DrawRect(&bmp, cbtn.left, cbtn.top, cbtn.right - cbtn.left, - cbtn.bottom - cbtn.top, kColBtnBorder, 1.0f, 0); - drawCenteredText(&bmp, cbtn, "+", kRgbBtnText, DT_CENTER); + const InteractionState createState = + hoverState(g_panel.hovered, HoverKind::CreateBank, -1); + drawButton(&bmp, KitButtonBox{KitBox{cbtn.left, cbtn.top, cbtn.right - cbtn.left, + cbtn.bottom - cbtn.top}}, + "+", createState, /*warn=*/false); drawTabStrip(&bmp, region); drawRegionGrid(&bmp, region, /*isBanks=*/true, indexForRegion(Region::Banks), @@ -1115,12 +1200,12 @@ void paintPanel(HWND hwnd, HDC hdc) { const RECT grid = regionGridRect(region, true); LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, grid.right - grid.left - 2, grid.bottom - grid.top - 2, - kColDropTarget, 1.0f, 0); + toLicePixel(roleColor(Role::AccentHot)), 1.0f, 0); } } drawModeSwitch(&bmp, w); - drawActionButtons(&bmp, w, h); // M11 button strip, above the footer + 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 @@ -1872,11 +1957,11 @@ void handleClick(int x, int y) { return; } - // Action-trigger button strip (M11): a click on a button fires the registered action - // via the command-id contract. Checked before the region chrome / grid so a strip - // click never selects a cell; the handler claims the whole strip band (a miss on the - // overflow dead-zone is a harmless no-op, not a fall-through to the grid below). - if (handleActionButtonClick(x, y)) 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). if (poolShown()) { @@ -2098,7 +2183,74 @@ void updateDropTarget(int x, int y) { } } +// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring +// handleClick's precedence exactly (so the element that lights on hover is the one a click +// would hit). Returns HoverKind::None for the grid / dead space / a point outside the client +// (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure +// resolution over the same pure geometry the click path uses. +Hover resolveHover(int x, int y) { + if (!g_panel.hwnd) return Hover{}; + RECT cr{}; + 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). + { + 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. + { + const int hit = actionBarHit(x, y); + if (hit >= 0) return Hover{HoverKind::ActionBarButton, hit}; + } + // Region chrome: full-height toggles, create button, tabs. + if (poolShown()) { + const RECT pr = poolRegionRect(w, h); + const RECT ftb = fullHtBtnRect(pr); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) + return Hover{HoverKind::FullHtPool, -1}; + } + if (banksShown()) { + const RECT br = banksRegionRect(w, h); + const RECT ftb = fullHtBtnRect(br); + if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) + return Hover{HoverKind::FullHtBanks, -1}; + const RECT cb = createBtnRect(br); + if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) + return Hover{HoverKind::CreateBank, -1}; + const TabStripRect strip = banksTabStripRect(br); + const std::vector tabs = namedBanks(); + const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), + kTabSpec, g_panel.tabScroll); + if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index}; + } + return Hover{}; +} + +// Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no +// per-move jank — the "speed is the selling point" repaint discipline). +void updateHover(int x, int y) { + const Hover next = resolveHover(x, y); + if (next != g_panel.hovered) { + g_panel.hovered = next; + invalidatePanel(); + } +} + void onMouseMove(int x, int y) { + // Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns + // the visual feedback then — a drop-target highlight, not a hover). Cleared to None when + // the pointer is over the grid / dead space. + if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); + if (g_panel.dragArmed && !g_panel.dragging) { if (std::abs(x - g_panel.dragStartX) > kDragThreshold || std::abs(y - g_panel.dragStartY) > kDragThreshold) { @@ -2106,6 +2258,7 @@ void onMouseMove(int x, int y) { g_panel.dragging = true; g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); g_panel.dragSampleIds = focusedSelectionIds(); + g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now SetCapture(g_panel.hwnd); } } @@ -2266,6 +2419,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { stopAudition(); g_panel.selection = Selection{}; g_panel.dragArmed = g_panel.dragging = false; + g_panel.hovered = Hover{}; g_panel.hwnd = nullptr; g_panel.open = false; return 0; diff --git a/src/draw_kit.cpp b/src/draw_kit.cpp index 7db22b7..379eea1 100644 --- a/src/draw_kit.cpp +++ b/src/draw_kit.cpp @@ -23,16 +23,17 @@ namespace reasampler { -namespace { - // --- KitColor <-> LICE boundary ---------------------------------------------- // The one place a pure KitColor becomes a LICE_pixel. Verified packing: LICE_RGBA(r,g,b,a) -// (lice.h:57). The theme owns the color; the shell owns the packing. +// (lice.h:57). The theme owns the color; the shell owns the packing. Declared in draw_kit.h +// so shell translation units (bank_panel) can use it without duplicating the LICE_RGBA pack. LICE_pixel toLice(const KitColor& c) { return LICE_RGBA(c.r, c.g, c.b, c.a); } +namespace { + // The draw alpha the LICE primitives take (0..1), from the KitColor's 8-bit alpha. Used so // a disabled surface (alpha 0.4) composites at the right opacity — LICE_FillRect etc. take // a float alpha argument separate from the pixel's own alpha byte. diff --git a/src/draw_kit.h b/src/draw_kit.h index 446d4f4..ae4eaa0 100644 --- a/src/draw_kit.h +++ b/src/draw_kit.h @@ -27,8 +27,15 @@ #include "peaks.h" // Envelope — the waveform primitive's input #include "theme.h" // Role / InteractionState / KitColor / TextClass -// LICE + SWELL types at the boundary (this is the shell half). Forward-declared where -// possible to keep the header light; the .cpp includes the full LICE/SWELL headers. +// LICE types at the boundary (this is the shell half). LICE_IBitmap is forward-declared +// to keep the header light. LICE_pixel is a typedef (unsigned int) — not forward-declarable +// — so the full lice.h is included only for the toLice() declaration; on Windows lice.h +// pulls in , which is fine since draw_kit.h is shell-only and never included by +// a pure module. +#ifdef _WIN32 +#include +#endif +#include "lice/lice.h" class LICE_IBitmap; namespace reasampler { @@ -46,6 +53,13 @@ enum class Font { // single-line convention); a caller wanting multi-line composes rows itself. enum class Align { Left, Center, Right }; +// --- KitColor → LICE_pixel conversion ---------------------------------------- + +// The one place a pure KitColor becomes a LICE_pixel. Declared here so any shell +// translation unit that already includes draw_kit.h can use it without duplicating +// the LICE_RGBA packing. Defined in draw_kit.cpp. +LICE_pixel toLice(const KitColor& c); + // --- Font lifecycle (owned by the kit) --------------------------------------- // Creates the four cached fonts once. Idempotent: a second call before shutdown is a no-op diff --git a/tests/test_action_bar.cpp b/tests/test_action_bar.cpp new file mode 100644 index 0000000..56e67cc --- /dev/null +++ b/tests/test_action_bar.cpp @@ -0,0 +1,325 @@ +// Standalone tests for reasampler::action_bar — no REAPER, no test framework. Same fast loop +// as the sibling pure tests (action_buttons / mode_switch / prune_button): assert the +// task-grouped action-bar layout, its keybinding sub-label sub-rects, overflow-on-narrow, and +// hit-testing directly. +// +// Covers (L2 brief §test cases): +// * Layout: correct rects for each action button across representative panel widths; buttons +// pack at a fixed width with intra-cluster + inter-cluster gaps. +// * Overflow/hiding when the bar is too narrow (whole trailing buttons dropped, never +// clipped; earlier frequent clusters survive; mirrors action_buttons suppression). +// * Keybinding sub-label sub-rects correct (label row + micro binding row split; too-short +// button collapses to label-only with an empty binding rect). +// * Task grouping reflected STRUCTURALLY: each slot carries its cluster; the flat index runs +// across clusters; inter-cluster gaps are wider than intra-cluster gaps. +// * Hover hit-test: right element for in-bounds points, -1 outside bounds AND in the gaps; +// degenerate/too-narrow bar handled without crash or overlap. +// * Resize: no inventory item cut off or overlapping across a representative width range. + +#include "../src/action_bar.h" + +#include +#include +#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) + +// The panel's real inventory shape: 4 capture, 2 placement, 2 maintenance = 8 buttons. +static std::vector inventory() { + return { + {ActionCluster::Capture, 4}, + {ActionCluster::Placement, 2}, + {ActionCluster::Maintenance, 2}, + }; +} + +// A spec with round numbers so expected pixels are hand-checkable. +static ActionBarSpec roundSpec() { + ActionBarSpec s; + s.buttonWidth = 100; + s.buttonGap = 4; + s.clusterGap = 16; + s.sidePad = 8; + s.verticalInset = 3; + s.bindingHeight = 11; + s.minSplitHeight = 30; + return s; +} + +// --- Layout: all fit, correct rects + gaps ------------------------------------ + +// A wide bar fits all 8 buttons. Verify the first few rects, the intra-cluster gap, and the +// (wider) inter-cluster gap between button 3 (last capture) and button 4 (first placement). +static void testAllFitRectsAndGaps() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + // Usable width: sidePad(8) + 8*100 + 6 intra gaps*4 + 2 cluster gaps*16 + sidePad(8) + // = 8 + 800 + 24 + 32 + 8 = 872. Give it 900. + ActionBarRect bar{0, 40, 900, 34}; + + BarFit fit = computeBarFit(bar, clusters, spec); + CHECK(fit.visibleCount == 8); + CHECK(fit.hiddenCount == 0); + + auto slots = computeBarSlots(bar, clusters, spec); + CHECK(slots.size() == 8); + + // Button 0: at sidePad, top = y + verticalInset, height = barH - 2*inset. + CHECK(slots[0].x == 8); + CHECK(slots[0].y == 43); + CHECK(slots[0].width == 100); + CHECK(slots[0].height == 28); + CHECK(slots[0].cluster == ActionCluster::Capture); + CHECK(slots[0].index == 0); + + // Button 1: intra-cluster gap of 4 after button 0's right edge (8+100=108) -> 112. + CHECK(slots[1].x == 112); + CHECK(slots[1].cluster == ActionCluster::Capture); + + // Button 3 is the last capture button. Its right edge: + // b0 8..108, +4 -> b1 112..212, +4 -> b2 216..316, +4 -> b3 320..420. + CHECK(slots[3].x == 320); + CHECK(slots[3].cluster == ActionCluster::Capture); + + // Button 4 (first placement): cluster gap of 16 after 420 -> 436. + CHECK(slots[4].x == 436); + CHECK(slots[4].cluster == ActionCluster::Placement); + CHECK(slots[4].index == 4); + + // Inter-cluster gap (436 - 420 = 16) is wider than the intra-cluster gap (4) — the task + // grouping is structurally visible in the geometry. + const int interGap = slots[4].x - (slots[3].x + slots[3].width); + const int intraGap = slots[1].x - (slots[0].x + slots[0].width); + CHECK(interGap == 16); + CHECK(intraGap == 4); + CHECK(interGap > intraGap); + + // Button 6 (first maintenance): b4 436..536, +4 -> b5 540..640, +16 -> b6 656..756. + CHECK(slots[6].x == 656); + CHECK(slots[6].cluster == ActionCluster::Maintenance); + CHECK(slots[6].index == 6); +} + +// --- Keybinding sub-label sub-rects -------------------------------------------- + +// A tall-enough button splits into a label row (top) and a micro binding row (bottom); the two +// abut, cover the button height, and sit inside the horizontal text inset. +static void testSubRectsSplit() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + ActionBarRect bar{0, 0, 900, 34}; // btnH = 34 - 6 = 28 >= minSplitHeight? 28 < 30 + // 28 < minSplitHeight(30) -> NOT split. Bump the bar so btnH >= 30. + bar.height = 40; // btnH = 40 - 6 = 34 >= 30 -> split + auto slots = computeBarSlots(bar, clusters, spec); + CHECK(!slots.empty()); + const ActionBarSlot& s = slots[0]; + CHECK(!s.bindingEmpty()); + // Binding row is the bottom bindingHeight(11); label is the remainder (34 - 11 = 23). + CHECK(s.bindH == 11); + CHECK(s.labelH == s.height - 11); + // The two rows abut with no gap/overlap and together span the button height. + CHECK(s.labelY == s.y); + CHECK(s.bindY == s.labelY + s.labelH); + CHECK(s.bindY + s.bindH == s.y + s.height); + // Both inset horizontally (text clears the button edge) and share the same inner width. + CHECK(s.labelX > s.x); + CHECK(s.labelX == s.bindX); + CHECK(s.labelW == s.bindW); + CHECK(s.labelX + s.labelW < s.x + s.width); +} + +// A short button (height below minSplitHeight) is NOT split: the label fills the interior and +// the binding sub-rect is empty (the shell draws only the label — graceful, no clipped micro). +static void testSubRectsNoSplitWhenShort() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + ActionBarRect bar{0, 0, 900, 24}; // btnH = 24 - 6 = 18 < minSplitHeight(30) + auto slots = computeBarSlots(bar, clusters, spec); + CHECK(!slots.empty()); + const ActionBarSlot& s = slots[0]; + CHECK(s.bindingEmpty()); + CHECK(s.labelH == s.height); // label fills the whole interior height + CHECK(s.labelY == s.y); +} + +// --- Overflow / hiding on a narrow panel -------------------------------------- + +// A bar wide enough for only the 4 capture buttons + a couple placement drops the rest WHOLE. +// The visible buttons keep their full width (never clipped), and the frequent capture cluster +// survives (overflow drops from the END). +static void testOverflowDropsTrailingWhole() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + // Room for exactly the 4 capture buttons: sidePad(8) + 4*100 + 3*4 = 420, +sidePad(8) = 428. + // A 5th button needs cluster gap 16 -> 428 + 16 + 100 = 544 > 430. So 430 fits exactly 4. + ActionBarRect bar{0, 0, 430, 34}; + BarFit fit = computeBarFit(bar, clusters, spec); + CHECK(fit.visibleCount == 4); + CHECK(fit.hiddenCount == 4); + + auto slots = computeBarSlots(bar, clusters, spec); + CHECK(slots.size() == 4); + for (const auto& s : slots) { + CHECK(s.width == spec.buttonWidth); // never clipped below full width + CHECK(s.cluster == ActionCluster::Capture);// the surviving cluster is the frequent one + } + // The last visible button's right edge stays within the usable bound. + CHECK(slots.back().x + slots.back().width <= bar.x + bar.width - spec.sidePad); +} + +// A bar too narrow for even one button lays out nothing (all hidden) — no sub-minimum clipped +// button; the shell draws an empty bar. +static void testTooNarrowForAny() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + ActionBarRect bar{0, 0, 60, 34}; // sidePad*2 + one 100-wide button won't fit + BarFit fit = computeBarFit(bar, clusters, spec); + CHECK(fit.visibleCount == 0); + CHECK(fit.hiddenCount == 8); + CHECK(computeBarSlots(bar, clusters, spec).empty()); +} + +// --- Degenerate -------------------------------------------------------------- + +static void testDegenerate() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + CHECK(computeBarSlots(ActionBarRect{0, 0, 0, 34}, clusters, spec).empty()); + CHECK(computeBarSlots(ActionBarRect{0, 0, 900, 0}, clusters, spec).empty()); + CHECK(computeBarSlots(ActionBarRect{0, 0, 900, 34}, {}, spec).empty()); + ActionBarSpec badW = spec; badW.buttonWidth = 0; + CHECK(computeBarSlots(ActionBarRect{0, 0, 900, 34}, clusters, badW).empty()); + + // Empty clusters in the list contribute no buttons and no gaps. + std::vector withEmpty = { + {ActionCluster::Capture, 2}, + {ActionCluster::Placement, 0}, // empty — skipped + {ActionCluster::Maintenance, 1}, + }; + auto slots = computeBarSlots(ActionBarRect{0, 0, 900, 34}, withEmpty, spec); + CHECK(slots.size() == 3); + CHECK(slots[0].cluster == ActionCluster::Capture); + CHECK(slots[1].cluster == ActionCluster::Capture); + CHECK(slots[2].cluster == ActionCluster::Maintenance); + // The cluster gap sits between the Capture and Maintenance buttons (Placement emitted none). + const int gap = slots[2].x - (slots[1].x + slots[1].width); + CHECK(gap == spec.clusterGap); +} + +// --- Hit-test: hits, gaps, and misses ----------------------------------------- + +static void testHitTestHitsButtons() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + ActionBarRect bar{0, 40, 900, 34}; + auto slots = computeBarSlots(bar, clusters, spec); + // A point in the middle of each button returns that button's flat index. + for (const auto& s : slots) { + const int cx = s.x + s.width / 2; + const int cy = s.y + s.height / 2; + CHECK(hitTestActionBar(cx, cy, bar, clusters, spec) == s.index); + } +} + +// A point in an intra-cluster gap and a point in an inter-cluster gap are both clean misses +// (real gaps, unlike an equal-tiled strip — no nearest-button snapping). +static void testHitTestGapsAreMisses() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + ActionBarRect bar{0, 40, 900, 34}; + auto slots = computeBarSlots(bar, clusters, spec); + // Intra-cluster gap between button 0 (right edge 108) and button 1 (left 112): x in [108,112). + CHECK(hitTestActionBar(110, 50, bar, clusters, spec) == -1); + // Inter-cluster gap between button 3 (right 420) and button 4 (left 436): x in [420,436). + CHECK(hitTestActionBar(428, 50, bar, clusters, spec) == -1); +} + +static void testHitTestMissesOutsideBand() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + ActionBarRect bar{10, 40, 400, 34}; + CHECK(hitTestActionBar(9, 50, bar, clusters, spec) == -1); // left of bar + CHECK(hitTestActionBar(410, 50, bar, clusters, spec) == -1); // right edge (excluded) + CHECK(hitTestActionBar(50, 39, bar, clusters, spec) == -1); // above the band + CHECK(hitTestActionBar(50, 74, bar, clusters, spec) == -1); // below the band +} + +// On a narrow bar the point past the last visible button (in the overflow dead-zone) misses. +static void testHitTestOverflowDeadZone() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + ActionBarRect bar{0, 0, 430, 34}; // only 4 capture buttons visible + // x well past the 4th button's right edge but still inside the bar band. + CHECK(hitTestActionBar(425, 10, bar, clusters, spec) == -1); +} + +static void testHitTestDegenerate() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + CHECK(hitTestActionBar(5, 5, ActionBarRect{0, 0, 0, 34}, clusters, spec) == -1); + CHECK(hitTestActionBar(5, 5, ActionBarRect{0, 0, 900, 0}, clusters, spec) == -1); + CHECK(hitTestActionBar(5, 5, ActionBarRect{0, 0, 900, 34}, {}, spec) == -1); +} + +// --- Resize sweep: no overlap, no cut-off, hit-test matches layout ------------ + +// Across a representative width range: every visible slot is fully inside the bar's usable +// area, no two slots overlap, and every point that hit-tests to a button lands inside that +// button's drawn rect (hit-test and layout agree — the load-bearing consistency invariant). +static void testResizeSweepNoOverlapNoCutoff() { + const auto clusters = inventory(); + const ActionBarSpec spec = roundSpec(); + for (int w = 60; w <= 1000; w += 7) { + ActionBarRect bar{0, 0, w, 34}; + auto slots = computeBarSlots(bar, clusters, spec); + int prevRight = bar.x + spec.sidePad - 1; + for (const auto& s : slots) { + // Inside the bar band. + CHECK(s.x >= bar.x); + CHECK(s.x + s.width <= bar.x + bar.width - spec.sidePad); + CHECK(s.y >= bar.y); + CHECK(s.y + s.height <= bar.y + bar.height); + // No overlap with the previous slot (strictly increasing, non-overlapping). + CHECK(s.x > prevRight); + prevRight = s.x + s.width - 1; + // Sub-rects stay inside the box. + CHECK(s.labelX >= s.x && s.labelX + s.labelW <= s.x + s.width); + if (!s.bindingEmpty()) { + CHECK(s.bindX >= s.x && s.bindX + s.bindW <= s.x + s.width); + CHECK(s.bindY + s.bindH <= s.y + s.height); + } + } + // Hit-test agrees with layout for a mid-height row across the whole band. + for (int px = bar.x; px < bar.x + bar.width; px += 3) { + const int hit = hitTestActionBar(px, bar.y + bar.height / 2, bar, clusters, spec); + if (hit >= 0) { + bool found = false; + for (const auto& s : slots) + if (s.index == hit && px >= s.x && px < s.x + s.width) found = true; + CHECK(found); + } + } + } +} + +int main() { + testAllFitRectsAndGaps(); + testSubRectsSplit(); + testSubRectsNoSplitWhenShort(); + testOverflowDropsTrailingWhole(); + testTooNarrowForAny(); + testDegenerate(); + testHitTestHitsButtons(); + testHitTestGapsAreMisses(); + testHitTestMissesOutsideBand(); + testHitTestOverflowDeadZone(); + testHitTestDegenerate(); + testResizeSweepNoOverlapNoCutoff(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}