From 7cd0771e4527d2b48ea2066076134502c19fe8f7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 23:56:49 -0400 Subject: [PATCH] =?UTF-8?q?L5:=20dock-panel=20button=20refinements=20?= =?UTF-8?q?=E2=80=94=20overflow=20menu,=20tooltips,=20opposite-mode=20tag?= =?UTF-8?q?=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-bar rare-capture variants move to a right-anchored More popup; short faces gain hover-delay full-name tooltips (prefix stripped); bottom bar becomes four opposite-mode Item/Track tag buttons + Show Both (Toggle/Activates dropped); wider cluster gaps. New pure overflow_menu/mode_enable/tooltip modules, CTest-covered. Same actions, same contract. --- CMakeLists.txt | 53 ++++- src/bank_panel.cpp | 395 ++++++++++++++++++++++++++++++----- src/mode_enable.cpp | 21 ++ src/mode_enable.h | 39 ++++ src/overflow_menu.cpp | 42 ++++ src/overflow_menu.h | 87 ++++++++ src/tooltip.cpp | 50 +++++ src/tooltip.h | 62 ++++++ tests/test_mode_enable.cpp | 64 ++++++ tests/test_overflow_menu.cpp | 144 +++++++++++++ tests/test_tooltip.cpp | 121 +++++++++++ 11 files changed, 1025 insertions(+), 53 deletions(-) create mode 100644 src/mode_enable.cpp create mode 100644 src/mode_enable.h create mode 100644 src/overflow_menu.cpp create mode 100644 src/overflow_menu.h create mode 100644 src/tooltip.cpp create mode 100644 src/tooltip.h create mode 100644 tests/test_mode_enable.cpp create mode 100644 tests/test_overflow_menu.cpp create mode 100644 tests/test_tooltip.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 870c145..c2c8b02 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -385,6 +385,42 @@ add_library(footer_bar STATIC src/footer_bar.cpp) target_include_directories(footer_bar PUBLIC src) target_link_libraries(footer_bar PUBLIC prune_button) +# --------------------------------------------------------------------------- +# 2q) Pure overflow_menu layout — NO REAPER, NO SWELL, NO LICE. The Phase L (L5, +# refinement 1) top-toolbar "⋯" More-button geometry: band rect -> right-anchored +# menu-button rect (suppressed when the band is too narrow) + the horizontal reserve +# the action_bar must leave for it, and point -> in/out hit-test. Split out so the +# button placement + reserve math is unit-tested outside the DAW; the bank_panel +# L1-kit draw + TrackPopupMenu popup + NamedCommandLookup/Main_OnCommand dispatch are +# DAW-verified. Mirror of prune_button / mode_switch. +# --------------------------------------------------------------------------- +add_library(overflow_menu STATIC src/overflow_menu.cpp) +target_include_directories(overflow_menu PUBLIC src) + +# --------------------------------------------------------------------------- +# 2r) Pure mode_enable predicate — NO REAPER, NO SWELL, NO LICE. The Phase L (L5, +# refinement 3) opposite-mode tag-button enablement: (active mode id, button target +# mode) -> live/disabled, so only the buttons for the OPPOSITE of the active mode are +# clickable. Split out so both active modes are covered by CTest (not only whichever a +# DAW pass sat in); the bank_panel reads the active mode from view().activeModeId() and +# draws disabled buttons in the kit Disabled state. Depends on view_mode_model for the +# seed mode-id constants (kArrangeModeId / kDesignModeId — ONE home for the ids). +# --------------------------------------------------------------------------- +add_library(mode_enable STATIC src/mode_enable.cpp) +target_include_directories(mode_enable PUBLIC src) +target_link_libraries(mode_enable PUBLIC view_mode_model) + +# --------------------------------------------------------------------------- +# 2s) Pure tooltip layout — NO REAPER, NO SWELL, NO LICE. The Phase L (L5, refinement 2) +# custom hover-delay tooltip's placement geometry (anchor rect + text extent + client +# bounds -> tooltip box, preferring below, flipping above near the bottom edge, clamped +# to the client) + the action DISPLAY-PREFIX strip helper. Split out so placement + the +# prefix strip are unit-tested outside the DAW; the bank_panel hover timer + LICE overlay +# draw are DAW-verified. Mirror of prune_button / component_geometry. +# --------------------------------------------------------------------------- +add_library(tooltip STATIC src/tooltip.cpp) +target_include_directories(tooltip PUBLIC src) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -503,6 +539,18 @@ 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) +add_executable(overflow_menu_tests tests/test_overflow_menu.cpp) +target_link_libraries(overflow_menu_tests PRIVATE overflow_menu) +add_test(NAME overflow_menu_tests COMMAND overflow_menu_tests) + +add_executable(mode_enable_tests tests/test_mode_enable.cpp) +target_link_libraries(mode_enable_tests PRIVATE mode_enable) +add_test(NAME mode_enable_tests COMMAND mode_enable_tests) + +add_executable(tooltip_tests tests/test_tooltip.cpp) +target_link_libraries(tooltip_tests PRIVATE tooltip) +add_test(NAME tooltip_tests COMMAND tooltip_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -548,8 +596,11 @@ add_library(reaper_reasampler MODULE src/drag_out_win.cpp src/action_bar.cpp src/footer_bar.cpp + src/overflow_menu.cpp + src/mode_enable.cpp + src/tooltip.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 footer_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 overflow_menu mode_enable tooltip) 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/bank_panel.cpp b/src/bank_panel.cpp index 05b4a3f..ba6132d 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -62,10 +62,13 @@ #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) +#include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5) #include "mode_switch.h" +#include "overflow_menu.h" // top-toolbar More-button geometry + reserve (pure, L5) #include "peaks.h" #include "persist.h" #include "prune_button.h" // footer prune-button layout + hit-test (pure, R3) +#include "tooltip.h" // tooltip placement + prefix-strip (pure, L5) #include "render_settings.h" // captureActionTable — the table-driven button rows (M11) #include "tab_strip.h" #include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) @@ -162,6 +165,16 @@ constexpr int kFooterHeight = 30; constexpr int kTopToolbarHeight = 40; // taller — hosts the label + keybinding micro sub-row constexpr int kBottomToolbarHeight = 40; // same shape (label + keybinding sub-row) +// --- Tooltip (Phase L, L5) ---------------------------------------------------- +// The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the +// platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the +// OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font +// is AA and proportional, so the width is estimated from a per-char average (the tooltip box is +// generous — a slight over/under-estimate only pads the box, never clips the text). +constexpr unsigned int kTooltipDelayMs = 500; +constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous) +constexpr int kTooltipTextH = 14; // approx line height at Font::Label + // --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) ------- // // The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) | @@ -208,6 +221,7 @@ enum class HoverKind { None, 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) + MoreButton, // the TOP toolbar's far-right "⋯" overflow-menu button (L5) PruneButton, FullHtPool, // pool region full-height toggle FullHtBanks, // banks region full-height toggle @@ -256,6 +270,17 @@ struct PanelState { // hover state). Repaint fires only when this changes (sub-frame, no per-move jank). Hover hovered; + // --- Tooltip (Phase L, L5) ------------------------------------------------ + // A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a + // TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel + // draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the + // GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover + // change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly + // once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the + // hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event. + unsigned int hoverSinceTick = 0; + bool tooltipShown = false; + // --- Vertical-split state ------------------------------------------------- BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; @@ -522,10 +547,19 @@ int modeCount() { return static_cast(g_panel.session->view().modes().size()); } -// --- Top toolbar band (L4) ---------------------------------------------------- +// --- Top toolbar band (L4; L5 overflow-menu reserve) -------------------------- // // 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. +// (height 0) when the client is too short to host it above the split body. The WHOLE band +// (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent +// buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run +// under the menu button (L5 refinement 1). + +// The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its +// geometry + the reserve the action_bar leaves for it. +const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6, + /*verticalInset=*/3, /*minLeftInset=*/40}; + ActionBarRect topToolbarRect(int w) { ActionBarRect s; s.x = 0; @@ -535,6 +569,31 @@ ActionBarRect topToolbarRect(int w) { return s; } +// The band the More button occupies (the whole top toolbar band as a MenuBarRect). +MenuBarRect topMenuBarRect(int w) { + const ActionBarRect bar = topToolbarRect(w); + return MenuBarRect{bar.x, bar.y, bar.width, bar.height}; +} + +// The More button's rect (right-anchored in the top band). Empty when the band is too narrow +// to place it clear of its left inset — the three variants stay reachable via their bindable +// commands (graceful suppression). +MenuButtonRect topMenuButtonRect(int w) { + return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec); +} + +// The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the +// far-right More button, so the frequent buttons never overlap it. When the More button is +// suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a +// degenerate band), which keeps draw and hit-test consistent whether or not the button shows. +ActionBarRect topToolbarActionRect(int w) { + ActionBarRect bar = topToolbarRect(w); + const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec); + bar.width -= reserve; + if (bar.width < 0) bar.width = 0; + return bar; +} + // --- Footer (L4) -------------------------------------------------------------- RECT panelFooter(int w, int h) { @@ -714,60 +773,113 @@ void markTailDirty() { // 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 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. +// at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action +// NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is +// dropped at build), 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. +// +// L5: the FACE stays short (shortLabel, sized to never overflow the button width); the FULL name +// (fullName) is the hover tooltip content. fullName is sourced from the SAME phrase the action +// was registered with (render_settings' descriptionPhrase for the capture scopes; the literal +// registered phrase otherwise) so the tooltip matches the Actions-list entry exactly — the +// "ReaSampler:" prefix is not stored here (the face/tooltip never show it, per L5 refinement 2). struct ActionBarRow { std::string suffix; std::string shortLabel; + std::string fullName; ActionCluster cluster = ActionCluster::Capture; + bool enabled = true; // L5: opposite-mode gate for the bottom-bar tag buttons; always true + // for the top bar (its actions are unconditional triggers). }; -// The TOP toolbar inventory: Capture (item / track / batch items / batch razor / RT) then -// 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. +// The TOP toolbar inventory (L5 refinement 1): the FREQUENT acts only — Capture (item / track) +// then Placement (insert / insert-conform) then Maintenance (re-capture / cancel RT). The three +// RARE capture variants (Batch Items / Batch Razor / Capture RT) are re-homed OFF the visible +// bar into the far-right "⋯" overflow menu (overflowMenuRows) — same registered actions, same +// command-id contract, just a different home. Capture scopes come from captureActionTable() +// (render_settings, pure); the rest are the registered M11/M10/M8 commands. Built once per +// draw/click. Each row carries its full (prefix-stripped) action name for the hover tooltip. std::vector topBarRows() { std::vector rows; - // Capture cluster — the primary gesture, leftmost. + // Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track"; + // the tooltip carries the full descriptionPhrase the action was registered with. for (const CaptureActionDef& def : captureActionTable()) { 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, ActionCluster::Capture}); + rows.push_back({def.commandSuffix, label, def.descriptionPhrase, + ActionCluster::Capture, true}); } - 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 — 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}); + rows.push_back({"INSERT_SELECTED", "Insert", + "insert selected sample at edit cursor", ActionCluster::Placement, true}); + rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", + "insert selected sample at edit cursor (conform to tempo)", + ActionCluster::Placement, true}); // 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}); + rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", + "re-capture from source", ActionCluster::Maintenance, true}); + rows.push_back({"CANCEL_REALTIME_CAPTURE", "Cancel RT", + "cancel realtime capture", ActionCluster::Maintenance, true}); return rows; } -// 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. +// The TOP-toolbar OVERFLOW menu inventory (L5 refinement 1): the three rare capture variants, +// pulled off the visible bar into the far-right "⋯" menu button's popup. Each fires the SAME +// existing registered command id via the SAME NamedCommandLookup/Main_OnCommand contract — no +// action changes. The fullName is the popup entry text (the terse shortLabel is unused for menu +// items; the popup has room for the full name). Order matches the L4 capture-cluster order. +std::vector overflowMenuRows() { + return { + {"CAPTURE_BATCH_ITEMS", "Batch Items", + "batch capture selected items (one per item)", ActionCluster::Capture, true}, + {"CAPTURE_BATCH_RAZOR", "Batch Razor", + "batch capture razor areas (one per area)", ActionCluster::Capture, true}, + {"CAPTURE_TRACK_REALTIME", "Capture RT", + "capture selected track (realtime)", ActionCluster::Capture, true}, + }; +} + +// The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for +// "which mode is active"). Empty when no session (every button then falls to fail-open live). +std::string activeModeIdOrEmpty() { + if (!g_panel.session) return {}; + return g_panel.session->view().activeModeId(); +} + +// The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons +// then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from +// actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; +// VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not +// paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). +// The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the +// footer [Arrange|Design] toggle owns mode switching. +// +// OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode +// (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from +// the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw +// Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the +// Track button for a target share the target's enablement. std::vector bottomBarRows() { + const std::string active = activeModeIdOrEmpty(); + const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange); + const bool designLive = tagButtonEnabled(active, TagTarget::Design); + 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}); + // Tagging cluster — the four Item/Track x Arrange/Design tag buttons. + rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange", + "move selected items -> Arrange", ActionCluster::Tagging, arrangeLive}); + rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design", + "move selected items -> Design", ActionCluster::Tagging, designLive}); + rows.push_back({"VIEW_TAG_ARRANGE", "Track: Arrange", + "tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive}); + rows.push_back({"VIEW_TAG_DESIGN", "Track: Design", + "tag selected tracks -> Design", ActionCluster::Tagging, designLive}); + // Switching cluster — Show Both, set apart (the only survivor of the old switching group). + rows.push_back({"VIEW_SHOW_BOTH", "Show Both", + "show both for selected tracks", ActionCluster::Switching, true}); return rows; } @@ -797,8 +909,10 @@ std::vector actionBarClusters(const std::vector& rows } // 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, +// by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5: +// clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups — the +// gap between clusters is visibly larger than the gap between buttons within a cluster. +const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24, /*sidePad=*/8, /*verticalInset=*/3, /*bindingHeight=*/11, /*minSplitHeight=*/30}; @@ -860,10 +974,11 @@ void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, 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.) + // State: Disabled when the action is not registered on this channel OR the row is gated + // off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); 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; + if (cmd == 0 || !row.enabled) state = InteractionState::Disabled; else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index) state = InteractionState::Hover; @@ -913,11 +1028,105 @@ bool handleToolbarClick(int x, int y, const ActionBarRect& bar, return y >= bar.y && y < bar.y + bar.height && x >= bar.x && x < bar.x + bar.width; } - const int cmd = resolveBarCommandId(rows[static_cast(hit)]); + const ActionBarRow& row = rows[static_cast(hit)]; + // A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the + // action and never falls through to the grid (a dead button reads as inert, not absent). + if (!row.enabled) return true; + const int cmd = resolveBarCommandId(row); if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); return true; } +// --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) ------------------- +// +// The three rare capture variants live only in this popup. The button is drawn kit-style (rest/ +// hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the +// variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand +// (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine +// for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure. + +// Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow). +void drawMoreButton(LICE_IBitmap* bmp, int w) { + const MenuButtonRect mb = topMenuButtonRect(w); + if (mb.empty()) return; + const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1); + const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}}; + drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); + // The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text + // path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More". + kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...", + Font::Label, Role::TextPrimary, Align::Center); +} + +// Opens the More popup: defined later (after the menuAppend/menuSeparator helpers), forward- +// declared here so drawMoreButton's neighbours read together. The click site (handleClick) sits +// after the definition, so no forward-declaration is strictly required — this documents intent. +void showMoreMenu(); + +// --- Tooltip (L5 refinement 2) ------------------------------------------------ +// +// A custom hover-delay tooltip: the full, prefix-stripped action name of the hovered toolbar +// button. Resolves the hovered element to its (anchor rect, text); returns false when the current +// hover has no tooltip (grid / chrome / the More button — the More button's own popup is its +// affordance). The tooltip DRAW is below; timing (kTooltipDelayMs) is applied by the caller. + +// The full (prefix-stripped) tooltip text for the currently hovered toolbar button, plus its +// anchor rect. Returns false when the hover is not a tooltip-bearing toolbar button. +bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& aw, int& ah) { + const Hover& hv = g_panel.hovered; + std::vector rows; + ActionBarRect bar{}; + if (hv.kind == HoverKind::TopBarButton) { + rows = topBarRows(); + bar = topToolbarActionRect(w); + } else if (hv.kind == HoverKind::BottomBarButton) { + rows = bottomBarRows(); + bar = bottomToolbarRect(w, h); + } else { + return false; + } + if (hv.index < 0 || hv.index >= static_cast(rows.size())) return false; + + // The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw + + // hit-test use, so the anchor matches the drawn button exactly. + const std::vector clusters = actionBarClusters(rows); + const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); + const ActionBarSlot* slot = nullptr; + for (const ActionBarSlot& s : slots) + if (s.index == hv.index) { slot = &s; break; } + if (!slot) return false; + + // The full name is stored already prefix-free, but strip defensively in case a source ever + // carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2). + textOut = stripActionPrefix(rows[static_cast(hv.index)].fullName, + actionDisplayPrefix()); + ax = slot->x; ay = slot->y; aw = slot->width; ah = slot->height; + return true; +} + +// Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current +// hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the +// paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the +// anchor, flipping above near the bottom edge, clamped to the client). +void drawTooltip(LICE_IBitmap* bmp, int w, int h) { + if (!g_panel.tooltipShown) return; + std::string txt; + int ax = 0, ay = 0, aw = 0, ah = 0; + if (!currentTooltip(w, h, txt, ax, ay, aw, ah) || txt.empty()) return; + + const int textW = static_cast(txt.size()) * kTooltipCharPx; + const TooltipBox tb = + computeTooltip(ax, ay, aw, ah, textW, kTooltipTextH, w, h, TooltipSpec{}); + if (tb.empty()) return; + + // The tooltip surface: a raised bg/cell chip with a hairline border, then the AA text. + const KitBox box{tb.x, tb.y, tb.width, tb.height}; + fillSurface(bmp, box, Role::BgCell, InteractionState::Hover); + LICE_DrawRect(bmp, tb.x, tb.y, tb.width, tb.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center); +} + // --- Split geometry ----------------------------------------------------------- // // Every rect below is derived from the client size + fullHeight state, and BOTH paint @@ -1259,15 +1468,23 @@ void paintPanel(HWND hwnd, HDC hdc) { } } - // 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, + // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles + // into the band MINUS the far-right More-button reserve; the More button is drawn over the + // band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show + // Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit + // over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the + // WHOLE top band first — otherwise the reserved right strip behind the More button is bare. + fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest); + drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton, /*topDivider=*/false); + drawMoreButton(&bmp, w); drawToolbar(&bmp, bottomToolbarRect(w, h), bottomBarRows(), HoverKind::BottomBarButton, /*topDivider=*/true); drawFooter(&bmp, w, h); + // The custom hover-delay tooltip overlays everything (L5 refinement 2). + drawTooltip(&bmp, w, h); + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } @@ -1861,6 +2078,41 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) { } } +// Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the +// chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup +// (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we +// resolve + fire the corresponding registered command id via the SAME contract the visible +// buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above. +void showMoreMenu() { + if (!g_panel.hwnd) return; + const std::vector rows = overflowMenuRows(); + if (rows.empty()) return; + + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const MenuButtonRect mb = topMenuButtonRect(cr.right - cr.left); + if (mb.empty()) return; + + HMENU menu = CreatePopupMenu(); + for (std::size_t i = 0; i < rows.size(); ++i) { + const int cmd = resolveBarCommandId(rows[i]); + // Grey a variant not registered on this channel (defensive — all three are registered). + menuAppend(menu, static_cast(i + 1), rows[i].fullName.c_str(), + /*grayed=*/cmd == 0); + } + + // Anchor the popup at the button's bottom-left, in screen coords. + POINT pt{mb.x, mb.y + mb.height}; + ClientToScreen(g_panel.hwnd, &pt); + const int chosen = TrackPopupMenu(menu, TPM_RETURNCMD, pt.x, pt.y, 0, g_panel.hwnd, nullptr); + DestroyMenu(menu); + + if (chosen >= 1 && chosen <= static_cast(rows.size())) { + const int cmd = resolveBarCommandId(rows[static_cast(chosen - 1)]); + if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); + } +} + // Shows the move/copy menu for the current selection (the SOURCE is the focused // region's bank). Lists every OTHER bank (pool + named) as a move destination, then a // copy submenu-free flat list (copy entries follow the move block). Move is the @@ -1986,10 +2238,18 @@ void handleClick(int x, int y) { GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; - // 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; + // TOP toolbar: the far-right More button first (its rect sits in the band's reserved right + // strip, outside the action rect), then the frequent capture/placement buttons. 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. + { + const MenuButtonRect mb = topMenuButtonRect(w); + if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; } + } + if (handleToolbarClick(x, y, topToolbarActionRect(w), topBarRows())) return; + // Claim the WHOLE top band (including the reserved right strip between the last button and + // the More button) so a click there is inert chrome, never a fall-through to the grid. + if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) 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 @@ -2279,9 +2539,12 @@ Hover resolveHover(int x, int y) { GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; - // TOP toolbar buttons (matching the click order — first zone top-to-bottom). + // TOP toolbar: the far-right More button, then the frequent buttons (matching the click + // order — first zone top-to-bottom). { - const int hit = toolbarHit(x, y, topToolbarRect(w), topBarRows()); + const MenuButtonRect mb = topMenuButtonRect(w); + if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1}; + const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows()); if (hit >= 0) return Hover{HoverKind::TopBarButton, hit}; } // Footer: mode-toggle segments, Tail button, then Prune (matching the click order). @@ -2323,11 +2586,32 @@ Hover resolveHover(int x, int y) { } // 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). +// per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also +// resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only +// appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the +// poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running. void updateHover(int x, int y) { const Hover next = resolveHover(x, y); if (next != g_panel.hovered) { g_panel.hovered = next; + g_panel.hoverSinceTick = GetTickCount(); + if (g_panel.tooltipShown) { g_panel.tooltipShown = false; } + invalidatePanel(); + } +} + +// Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past +// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the +// OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer; +// WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the +// current hover has no tooltip (grid / chrome / the More button). +void maybeShowTooltip() { + if (g_panel.tooltipShown) return; + const HoverKind k = g_panel.hovered.kind; + if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return; + const unsigned int now = GetTickCount(); + if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) { + g_panel.tooltipShown = true; invalidatePanel(); } } @@ -2346,6 +2630,7 @@ void onMouseMove(int x, int y) { g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); g_panel.dragSampleIds = focusedSelectionIds(); g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now + g_panel.tooltipShown = false; // a drag never shows a tooltip SetCapture(g_panel.hwnd); } } @@ -2507,6 +2792,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { g_panel.selection = Selection{}; g_panel.dragArmed = g_panel.dragging = false; g_panel.hovered = Hover{}; + g_panel.tooltipShown = false; g_panel.hwnd = nullptr; g_panel.open = false; return 0; @@ -2622,6 +2908,11 @@ void bankPanelRefresh() { } if (!g_panel.open || !g_panel.hwnd) return; + + // L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a + // toolbar button has rested under the pointer past the delay, latch + repaint the tooltip. + maybeShowTooltip(); + if (refreshFingerprint()) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } diff --git a/src/mode_enable.cpp b/src/mode_enable.cpp new file mode 100644 index 0000000..84df3ce --- /dev/null +++ b/src/mode_enable.cpp @@ -0,0 +1,21 @@ +// mode_enable — pure implementation. See mode_enable.h. NO REAPER / SWELL / LICE / vendor. + +#include "mode_enable.h" + +#include "view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids + +namespace reasampler { + +bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { + // The target's own mode id, so the rule is a single "target != active" compare. + const char* targetId = + (target == TagTarget::Arrange) ? kArrangeModeId : kDesignModeId; + + // Fail-open on an unrecognized active id (neither seed mode): every button live, so a + // future added mode never dead-locks the bar and the user can always reach the action. + if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true; + + return activeModeId != targetId; +} + +} // namespace reasampler diff --git a/src/mode_enable.h b/src/mode_enable.h new file mode 100644 index 0000000..8391a46 --- /dev/null +++ b/src/mode_enable.h @@ -0,0 +1,39 @@ +#pragma once +// mode_enable — the REAPER-free opposite-mode enablement predicate behind the bank_panel BOTTOM +// toolbar's four Item/Track × Arrange/Design tag buttons (Phase L, L5, refinement 3). Each tag +// button sends the selection to a TARGET mode; a button is meaningful ONLY when its target is +// the OPPOSITE of the currently active mode. When Design is active the two "…: Arrange" buttons +// are live and the two "…: Design" buttons are dead (already there); when Arrange is active the +// reverse. This module owns that one decision — (active mode, button target) -> live/disabled — +// as a pure predicate, unit-tested for both active modes; the shell reads the active mode from +// view().activeModeId() (the SAME source the footer toggle reads — one source of truth for +// "which mode is active") and draws the disabled buttons in the kit Disabled state. +// +// Why pure: which button is live is a decision, not a draw or a DAW behaviour. Keeping it here +// means the shell cannot drift the enablement from the rule, and both active modes are covered +// by CTest, not only whichever one a manual DAW pass happened to sit in. +// +// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. + +#include + +namespace reasampler { + +// A tag button's TARGET mode — the mode it sends the selection to when fired. Arrange = the +// untagged default (returning the selection to Arrange), Design = tagged into the Design mode. +// The Item/Track axis is orthogonal to enablement (both Item and Track buttons for a target +// enable/disable together), so it is NOT modelled here — the shell carries it per button. +enum class TagTarget { + Arrange, + Design, +}; + +// True iff a tag button whose target is `target` should be LIVE (clickable), given the active +// mode id `activeModeId` (as returned by ViewModeModel::activeModeId() — the mode ids are the +// pure `kArrangeModeId` / `kDesignModeId` constants). The rule: a button is live iff its target +// differs from the active mode — you tag INTO the mode you are not currently in. An unrecognized +// active id (neither arrange nor design) leaves every button live (fail-open: never silently +// disable an action the user can still reach), so a future added mode never dead-locks the bar. +bool tagButtonEnabled(const std::string& activeModeId, TagTarget target); + +} // namespace reasampler diff --git a/src/overflow_menu.cpp b/src/overflow_menu.cpp new file mode 100644 index 0000000..04b1d3b --- /dev/null +++ b/src/overflow_menu.cpp @@ -0,0 +1,42 @@ +// overflow_menu — pure implementation. See overflow_menu.h. NO REAPER / SWELL / LICE / vendor. + +#include "overflow_menu.h" + +namespace reasampler { + +int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) { + if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return 0; + // The reserve is the button width plus a right gap (rightInset) and a matching left gap + // (also rightInset) so the frequent buttons have breathing room before the menu button. + return spec.buttonWidth + 2 * spec.rightInset; +} + +MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec) { + MenuButtonRect btn; + if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return btn; + + const int right = bar.x + bar.width - spec.rightInset; + const int left = right - spec.buttonWidth; + if (left < bar.x + spec.minLeftInset) return btn; // too narrow — suppress + + int top = bar.y + spec.verticalInset; + int height = bar.height - 2 * spec.verticalInset; + if (height <= 0) { // thin band: clamp to the band's own extents rather than go negative + top = bar.y; + height = bar.height; + } + + btn.x = left; + btn.y = top; + btn.width = spec.buttonWidth; + btn.height = height; + return btn; +} + +bool hitTestMenuButton(int px, int py, const MenuButtonRect& button) { + if (button.empty()) return false; + return px >= button.x && px < button.x + button.width && + py >= button.y && py < button.y + button.height; +} + +} // namespace reasampler diff --git a/src/overflow_menu.h b/src/overflow_menu.h new file mode 100644 index 0000000..88764fc --- /dev/null +++ b/src/overflow_menu.h @@ -0,0 +1,87 @@ +#pragma once +// overflow_menu — the REAPER-free layout math behind the bank_panel TOP toolbar's "⋯ / More" +// overflow-menu button (Phase L, L5, refinement 1). The rare capture variants (Batch Items / +// Batch Razor / Capture RT) move OFF the always-visible top bar into a popup opened by a small +// square button pinned to the FAR RIGHT of the top toolbar band. This module owns two things, +// both unit-tested outside the DAW: +// * WHERE the More button sits in the top toolbar band (right-anchored, vertically inset); +// * the horizontal RESERVE the action_bar must leave for it, so the frequent buttons never +// run under the menu button (the shell shrinks the action_bar's usable width by this). +// The popup itself (TrackPopupMenu) + the command dispatch is shell — a transient OS menu, not +// panel chrome (brief §1: "a REAPER/host popup menu is acceptable"). Only the button +// geometry + hit-test live here. +// +// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. +// Mirror of prune_button / mode_switch. The bar rect type it consumes mirrors action_bar's +// ActionBarRect shape but is named distinctly to avoid coupling the two modules. + +namespace reasampler { + +// The toolbar band the button is drawn into, top-left origin (SWELL/LICE convention). The +// shell derives this from topToolbarRect(). A distinct type from action_bar::ActionBarRect so +// this module stands alone (same shape; deliberate — the two modules are not coupled). +struct MenuBarRect { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + bool operator==(const MenuBarRect& o) const { + return x == o.x && y == o.y && width == o.width && height == o.height; + } +}; + +// The More button's pixel rectangle within the band, top-left origin. A zero-area rect +// (width <= 0 or height <= 0) means "no button" — the band is degenerate or too narrow to +// place the button clear of its left inset; the caller must not draw or hit-test it. The +// three variants stay reachable via their bindable commands, so a suppressed button is +// graceful, not a lost affordance. +struct MenuButtonRect { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + bool empty() const { return width <= 0 || height <= 0; } + + bool operator==(const MenuButtonRect& o) const { + return x == o.x && y == o.y && width == o.width && height == o.height; + } +}; + +// Layout inputs for the More button, in pixels. Defaults match the bank_panel top-toolbar +// metrics; the shell passes its own so draw and hit-test share one source of truth. +// * buttonWidth — the button's fixed width (a compact square-ish glyph button). +// * rightInset — gap from the band's right edge to the button's right edge. +// * verticalInset — top/bottom gap inside the band (shorter than the band so it reads as a +// raised control, matching the action_bar buttons' verticalInset). +// * minLeftInset — the button's left edge must stay at least this far from the band left +// edge; if it would encroach past this, computeMenuButton yields an empty +// rect (button suppressed). +struct MenuButtonSpec { + int buttonWidth = 28; + int rightInset = 6; + int verticalInset = 3; + int minLeftInset = 40; +}; + +// The horizontal reserve (px) the action_bar must leave at the band's right so its buttons +// never run under the More button: the button width + both insets (right gap + a matching +// left breathing gap equal to rightInset). The shell subtracts this from the action_bar rect's +// width before laying out slots. Returns 0 for a degenerate band (nothing to reserve). +int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec); + +// Computes the More button's rect within `bar` per `spec`. Right-anchored: the button's right +// edge is bar.x + bar.width - rightInset, its width is buttonWidth, vertically centred by +// verticalInset. Returns an EMPTY rect when: the band is degenerate (width/height <= 0), the +// buttonWidth is non-positive, OR the resulting left edge would fall closer to the band left +// than minLeftInset. A thin band clamps the button height to the band's own rather than going +// negative (mirror of computePruneButton). +MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec); + +// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`. +// Half-open bounds [x, x+width) x [y, y+height) — matches computeMenuButton so draw and +// hit-test agree on the same pixels. An empty button never claims a point (always false). +bool hitTestMenuButton(int px, int py, const MenuButtonRect& button); + +} // namespace reasampler diff --git a/src/tooltip.cpp b/src/tooltip.cpp new file mode 100644 index 0000000..0505f24 --- /dev/null +++ b/src/tooltip.cpp @@ -0,0 +1,50 @@ +// tooltip — pure implementation. See tooltip.h. NO REAPER / SWELL / LICE / vendor. + +#include "tooltip.h" + +namespace reasampler { + +std::string stripActionPrefix(const std::string& fullName, const std::string& prefix) { + if (prefix.empty()) return fullName; + if (fullName.size() >= prefix.size() && + fullName.compare(0, prefix.size(), prefix) == 0) + return fullName.substr(prefix.size()); + return fullName; +} + +TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, + int textW, int textH, int clientW, int clientH, + const TooltipSpec& spec) { + TooltipBox box; + if (textW <= 0 || textH <= 0 || clientW <= 0 || clientH <= 0) return box; + + const int boxW = textW + 2 * spec.padX; + const int boxH = textH + 2 * spec.padY; + + // Horizontal: centre on the anchor, then clamp within [margin, clientW - margin - boxW]. + int x = anchorX + (anchorW - boxW) / 2; + const int maxX = clientW - spec.margin - boxW; + if (x > maxX) x = maxX; + if (x < spec.margin) x = spec.margin; + + // Vertical: prefer BELOW the anchor; flip ABOVE if it would clip the bottom edge. + int y = anchorY + anchorH + spec.gap; + if (y + boxH > clientH - spec.margin) { + const int above = anchorY - spec.gap - boxH; + if (above >= spec.margin) { + y = above; // fits above — flip + } else { + // Fits neither cleanly (tall tooltip / short client): clamp to the bottom margin. + const int maxY = clientH - spec.margin - boxH; + y = maxY < spec.margin ? spec.margin : maxY; + } + } + + box.x = x; + box.y = y; + box.width = boxW; + box.height = boxH; + return box; +} + +} // namespace reasampler diff --git a/src/tooltip.h b/src/tooltip.h new file mode 100644 index 0000000..38be963 --- /dev/null +++ b/src/tooltip.h @@ -0,0 +1,62 @@ +#pragma once +// tooltip — the REAPER-free layout math + text helper behind the bank_panel's custom hover-delay +// tooltip (Phase L, L5, refinement 2). Button FACES stay short (the terse shortLabel); hovering a +// button for a short delay pops a small tooltip carrying the FULL action name with the +// "ReaSampler:" display prefix stripped. The tooltip is a custom LICE-kit draw (NOT the native +// Win32 / SWELL tooltip control) — chosen so it is uniform across platforms and consistent with +// the L1 kit (brief §tooltip mechanism). The DAW-bound parts (the hover timer, the LICE overlay +// draw, the kbd/action-name query) live in the shell; what is NOT DAW-bound — WHERE the tooltip +// box sits relative to its anchor button within the panel client, and stripping the display +// prefix — lives here, unit-tested outside the DAW. Mirror of prune_button / component_geometry. +// +// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. + +#include + +namespace reasampler { + +// The tooltip's box (top-left origin, SWELL/LICE convention). A zero-area rect means "do not +// draw" (degenerate inputs); the caller checks empty() before drawing. +struct TooltipBox { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + bool empty() const { return width <= 0 || height <= 0; } + + bool operator==(const TooltipBox& o) const { + return x == o.x && y == o.y && width == o.width && height == o.height; + } +}; + +// Placement inputs, in pixels. +// * gap — vertical gap between the anchor button and the tooltip box. +// * padX/padY — horizontal / vertical text padding inside the box. +// * margin — minimum clearance kept from the client edges when clamping. +struct TooltipSpec { + int gap = 4; + int padX = 6; + int padY = 3; + int margin = 2; +}; + +// Strips the action DISPLAY PREFIX from a full action name for the tooltip face. The registered +// gaccel name is composed as `prefix + phrase` (prefix from actionDisplayPrefix(), e.g. +// "ReaSampler: "); the tooltip shows only the phrase. If `fullName` does not start with +// `prefix`, it is returned unchanged (defensive — a name from an unexpected source still shows). +// An empty prefix returns fullName unchanged. +std::string stripActionPrefix(const std::string& fullName, const std::string& prefix); + +// Places a tooltip of pixel size (textW + 2*padX) x (textH + 2*padY) for the button rect +// (anchorX, anchorY, anchorW, anchorH), clamped inside the client rect (0,0,clientW,clientH). +// Preference: BELOW the anchor, horizontally centred on it. If it would clip the bottom edge, +// it flips ABOVE the anchor. It is then clamped horizontally (and vertically as a last resort) +// to stay within `margin` of the client edges. Returns an empty box when the text extent or the +// client is degenerate. `textW`/`textH` are the measured text extents (the shell measures with +// the kit font before calling). +TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, + int textW, int textH, int clientW, int clientH, + const TooltipSpec& spec); + +} // namespace reasampler diff --git a/tests/test_mode_enable.cpp b/tests/test_mode_enable.cpp new file mode 100644 index 0000000..2e8effc --- /dev/null +++ b/tests/test_mode_enable.cpp @@ -0,0 +1,64 @@ +// Standalone tests for reasampler::mode_enable — no REAPER, no test framework. Asserts the +// opposite-mode tag-button enablement predicate for BOTH active modes (the acceptance criterion: +// covered for both, not only whichever a DAW pass sat in). +// +// The rule (L5 refinement 3): a tag button whose target is `t` is LIVE iff `t` != the active +// mode — you tag INTO the mode you are not currently in. When Design is active, "…: Arrange" +// buttons are live and "…: Design" buttons are dead; when Arrange is active, the reverse. An +// unrecognized active id fails OPEN (every button live) so a future mode never dead-locks the bar. + +#include "../src/mode_enable.h" +#include "../src/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ids the rule keys off + +#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) + +// When ARRANGE is active: only the Design-target buttons are live (tag into Design); the +// Arrange-target buttons are dead (already in Arrange — nothing to do). +static void testArrangeActive() { + const std::string active = kArrangeModeId; + CHECK(!tagButtonEnabled(active, TagTarget::Arrange)); // dead — already active mode + CHECK(tagButtonEnabled(active, TagTarget::Design)); // live — opposite mode +} + +// When DESIGN is active: the reverse — only the Arrange-target buttons are live. +static void testDesignActive() { + const std::string active = kDesignModeId; + CHECK(tagButtonEnabled(active, TagTarget::Arrange)); // live — opposite mode + CHECK(!tagButtonEnabled(active, TagTarget::Design)); // dead — already active mode +} + +// Exactly one of the two targets is live in each mode (the buttons are complementary — a real +// pair, never both-live or both-dead). This is the invariant the disabled/enabled visuals rely on. +static void testExactlyOneTargetLivePerMode() { + CHECK(tagButtonEnabled(kArrangeModeId, TagTarget::Arrange) != + tagButtonEnabled(kArrangeModeId, TagTarget::Design)); + CHECK(tagButtonEnabled(kDesignModeId, TagTarget::Arrange) != + tagButtonEnabled(kDesignModeId, TagTarget::Design)); +} + +// An unrecognized active id (neither seed mode — e.g. empty when no session, or a future mode) +// fails OPEN: every button live, so the user can always reach the action. +static void testUnknownActiveFailsOpen() { + CHECK(tagButtonEnabled("", TagTarget::Arrange)); + CHECK(tagButtonEnabled("", TagTarget::Design)); + CHECK(tagButtonEnabled("some-future-mode", TagTarget::Arrange)); + CHECK(tagButtonEnabled("some-future-mode", TagTarget::Design)); +} + +int main() { + testArrangeActive(); + testDesignActive(); + testExactlyOneTargetLivePerMode(); + testUnknownActiveFailsOpen(); + + if (g_fail == 0) std::printf("mode_enable: all tests passed\n"); + else std::printf("mode_enable: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_overflow_menu.cpp b/tests/test_overflow_menu.cpp new file mode 100644 index 0000000..0a064bb --- /dev/null +++ b/tests/test_overflow_menu.cpp @@ -0,0 +1,144 @@ +// Standalone tests for reasampler::overflow_menu — no REAPER, no test framework. Same fast +// loop as the sibling pure tests (prune_button / mode_switch): assert the top-toolbar More +// ("⋯") button placement, the action-bar reserve, and hit-testing directly. +// +// Covers (L5 refinement 1 pure module): right-anchored layout in a wide band; the reserve the +// action_bar must leave (width + 2*rightInset); vertical inset; thin-band height clamp; +// SUPPRESSION (empty rect) when the band is too narrow to clear the left inset or is degenerate; +// a degenerate band reserves nothing; hit-test in/out/edge (half-open bounds); an empty button +// claims no point; draw and hit-test agree over the whole rect. + +#include "../src/overflow_menu.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) + +// --- Layout: wide band, right-anchored --------------------------------------- + +// Band 400 wide at origin (0, 0), height 40. Default spec: buttonWidth 28, rightInset 6, +// verticalInset 3, minLeftInset 40. Right edge = 0+400-6 = 394, left = 394-28 = 366 +// (>= 0+40, placed). Top = 0+3 = 3, height = 40-6 = 34. +static void testWideBandRightAnchored() { + MenuBarRect bar{0, 0, 400, 40}; + const MenuButtonRect b = computeMenuButton(bar, MenuButtonSpec{}); + CHECK(!b.empty()); + CHECK((b == MenuButtonRect{366, 3, 28, 34})); + CHECK(b.x + b.width == bar.x + bar.width - 6); // right edge at rightInset + CHECK(b.x >= bar.x + 40); // clears the left inset +} + +// Origin offset honoured (button anchors to THIS band's right). +static void testOffsetBandAnchors() { + MenuBarRect bar{10, 5, 400, 40}; + const MenuButtonRect b = computeMenuButton(bar, MenuButtonSpec{}); + CHECK(!b.empty()); + CHECK(b.x + b.width == bar.x + bar.width - 6); // = 10+400-6 = 404 + CHECK(b.y == 8); // 5 + 3 +} + +// --- Reserve ----------------------------------------------------------------- + +// The reserve = buttonWidth + 2*rightInset so the frequent buttons get a right gap AND a left +// breathing gap before the menu button. Default: 28 + 2*6 = 40. +static void testReserve() { + MenuBarRect bar{0, 0, 400, 40}; + CHECK(menuButtonReserve(bar, MenuButtonSpec{}) == 40); +} + +// A degenerate band reserves nothing (no button to reserve for). +static void testDegenerateBandReservesNothing() { + CHECK(menuButtonReserve(MenuBarRect{0, 0, 0, 40}, MenuButtonSpec{}) == 0); + CHECK(menuButtonReserve(MenuBarRect{0, 0, 400, 0}, MenuButtonSpec{}) == 0); + MenuButtonSpec zero{}; zero.buttonWidth = 0; + CHECK(menuButtonReserve(MenuBarRect{0, 0, 400, 40}, zero) == 0); +} + +// --- Suppression: too narrow / degenerate ------------------------------------ + +// The button is suppressed when its left edge would fall past minLeftInset. left = x + width - +// rightInset - buttonWidth. Need left < x + minLeftInset -> +// width < rightInset + buttonWidth + minLeftInset = 6 + 28 + 40 = 74. Width 73 suppresses; 74 +// places (boundary). +static void testNarrowBandSuppressed() { + CHECK(computeMenuButton(MenuBarRect{0, 0, 73, 40}, MenuButtonSpec{}).empty()); + CHECK(!computeMenuButton(MenuBarRect{0, 0, 74, 40}, MenuButtonSpec{}).empty()); +} + +static void testDegenerateBandSuppressed() { + CHECK(computeMenuButton(MenuBarRect{0, 0, 0, 40}, MenuButtonSpec{}).empty()); + CHECK(computeMenuButton(MenuBarRect{0, 0, 400, 0}, MenuButtonSpec{}).empty()); + MenuButtonSpec zero{}; zero.buttonWidth = 0; + CHECK(computeMenuButton(MenuBarRect{0, 0, 400, 40}, zero).empty()); +} + +// A thin band (height <= 2*verticalInset) still places a button, clamped to the band's height. +static void testThinBandClampsHeight() { + MenuBarRect bar{0, 0, 400, 4}; // 4 <= 2*3, so height would be negative -> clamp + const MenuButtonRect b = computeMenuButton(bar, MenuButtonSpec{}); + CHECK(!b.empty()); + CHECK(b.y == bar.y); + CHECK(b.height == bar.height); +} + +// --- Hit-test ---------------------------------------------------------------- + +static void testHitTestInside() { + MenuBarRect bar{0, 0, 400, 40}; + const MenuButtonRect b = computeMenuButton(bar, MenuButtonSpec{}); // {366,3,28,34} + CHECK(hitTestMenuButton(b.x, b.y, b)); // top-left inclusive + CHECK(hitTestMenuButton(b.x + b.width - 1, b.y + b.height - 1, b)); // bottom-right inclusive + CHECK(hitTestMenuButton(b.x + b.width / 2, b.y + b.height / 2, b)); // centre +} + +static void testHitTestEdgesExcluded() { + MenuBarRect bar{0, 0, 400, 40}; + const MenuButtonRect b = computeMenuButton(bar, MenuButtonSpec{}); + CHECK(!hitTestMenuButton(b.x - 1, b.y, b)); // just left + CHECK(!hitTestMenuButton(b.x + b.width, b.y, b)); // right edge excluded + CHECK(!hitTestMenuButton(b.x, b.y - 1, b)); // just above + CHECK(!hitTestMenuButton(b.x, b.y + b.height, b)); // bottom edge excluded +} + +// An empty/suppressed button claims no point — a click where it would be falls through. +static void testEmptyButtonClaimsNothing() { + MenuButtonRect empty{}; + CHECK(!hitTestMenuButton(0, 0, empty)); + const MenuButtonRect suppressed = computeMenuButton(MenuBarRect{0, 0, 50, 40}, MenuButtonSpec{}); + CHECK(suppressed.empty()); + CHECK(!hitTestMenuButton(30, 20, suppressed)); +} + +// Draw/hit-test agreement over the whole rect + the four immediate outside neighbours. +static void testHitTestMatchesLayout() { + MenuBarRect bar{3, 7, 377, 38}; // awkward origin/size + const MenuButtonRect b = computeMenuButton(bar, MenuButtonSpec{}); + CHECK(!b.empty()); + for (int py = b.y; py < b.y + b.height; ++py) + for (int px = b.x; px < b.x + b.width; ++px) + CHECK(hitTestMenuButton(px, py, b)); + CHECK(!hitTestMenuButton(b.x - 1, b.y, b)); + CHECK(!hitTestMenuButton(b.x + b.width, b.y, b)); +} + +int main() { + testWideBandRightAnchored(); + testOffsetBandAnchors(); + testReserve(); + testDegenerateBandReservesNothing(); + testNarrowBandSuppressed(); + testDegenerateBandSuppressed(); + testThinBandClampsHeight(); + testHitTestInside(); + testHitTestEdgesExcluded(); + testEmptyButtonClaimsNothing(); + testHitTestMatchesLayout(); + + if (g_fail == 0) std::printf("overflow_menu: all tests passed\n"); + else std::printf("overflow_menu: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_tooltip.cpp b/tests/test_tooltip.cpp new file mode 100644 index 0000000..5b99d4e --- /dev/null +++ b/tests/test_tooltip.cpp @@ -0,0 +1,121 @@ +// Standalone tests for reasampler::tooltip — no REAPER, no test framework. Asserts the custom +// hover-delay tooltip's placement geometry and the action display-prefix strip helper. +// +// Covers (L5 refinement 2 pure module): prefix strip (present / absent / empty prefix / exact +// match); placement BELOW the anchor centred; horizontal clamp at both client edges; the +// bottom-edge flip to ABOVE; the both-clip clamp for a tall tooltip; degenerate inputs -> empty. + +#include "../src/tooltip.h" + +#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) + +// --- Prefix strip ------------------------------------------------------------ + +static void testStripPresent() { + CHECK(stripActionPrefix("ReaSampler: capture selected track(s)", "ReaSampler: ") == + "capture selected track(s)"); +} + +static void testStripAbsent() { + // No prefix present -> returned unchanged (defensive: a name from an unexpected source shows). + CHECK(stripActionPrefix("capture selected track(s)", "ReaSampler: ") == + "capture selected track(s)"); +} + +static void testStripEmptyPrefix() { + CHECK(stripActionPrefix("anything", "") == "anything"); +} + +static void testStripExactMatchLeavesEmpty() { + // Name equals the prefix exactly -> the remainder is empty. + CHECK(stripActionPrefix("ReaSampler: ", "ReaSampler: ").empty()); +} + +static void testStripBetaPrefix() { + CHECK(stripActionPrefix("ReaSampler beta: show version", "ReaSampler beta: ") == + "show version"); +} + +// --- Placement: below, centred ----------------------------------------------- + +// Anchor button at (100, 10) size 108x34 in a 400x300 client. Text 80x14, spec defaults +// (gap 4, padX 6, padY 3, margin 2). boxW = 80+12 = 92, boxH = 14+6 = 20. +// Centre x = 100 + (108-92)/2 = 100 + 8 = 108. Below y = 10 + 34 + 4 = 48. +static void testBelowCentred() { + const TooltipBox tb = computeTooltip(100, 10, 108, 34, 80, 14, 400, 300, TooltipSpec{}); + CHECK(!tb.empty()); + CHECK((tb == TooltipBox{108, 48, 92, 20})); +} + +// --- Horizontal clamp -------------------------------------------------------- + +// A button near the RIGHT edge pushes the centred box past the client; it clamps to +// clientW - margin - boxW. Client 400, boxW 92, margin 2 -> maxX = 400-2-92 = 306. +static void testClampRight() { + const TooltipBox tb = computeTooltip(360, 10, 108, 34, 80, 14, 400, 300, TooltipSpec{}); + CHECK(!tb.empty()); + CHECK(tb.x == 306); +} + +// A button near the LEFT edge clamps x to the left margin (2). +static void testClampLeft() { + const TooltipBox tb = computeTooltip(0, 10, 20, 34, 80, 14, 400, 300, TooltipSpec{}); + CHECK(!tb.empty()); + CHECK(tb.x == 2); +} + +// --- Vertical flip ----------------------------------------------------------- + +// A button near the BOTTOM edge: below would clip, so the box flips ABOVE the anchor. +// Anchor at y=270 h=34 in a 300-tall client. Below y = 270+34+4 = 308, box bottom 308+20=328 > +// 300-2 -> flip above: y = 270 - 4 - 20 = 246 (>= margin, so used). +static void testFlipAbove() { + const TooltipBox tb = computeTooltip(100, 270, 108, 34, 80, 14, 400, 300, TooltipSpec{}); + CHECK(!tb.empty()); + CHECK(tb.y == 246); +} + +// A tall tooltip in a short client fits neither below nor above cleanly -> clamped to the +// bottom margin (never negative). Client 40 tall, box 20 tall, anchor filling it. +static void testBothClipClampsBottom() { + // anchor 0..30 in a 40-tall client, text tall enough that above < margin too. + const TooltipBox tb = computeTooltip(100, 5, 108, 30, 80, 30, 400, 40, TooltipSpec{}); + CHECK(!tb.empty()); + // boxH = 36; below y = 5+30+4=39 clips; above = 5-4-36 = -35 < margin; clamp to maxY = + // 40-2-36 = 2 (>= margin). + CHECK(tb.y == 2); +} + +// --- Degenerate -------------------------------------------------------------- + +static void testDegenerateEmpty() { + CHECK(computeTooltip(0, 0, 100, 30, 0, 14, 400, 300, TooltipSpec{}).empty()); // no text width + CHECK(computeTooltip(0, 0, 100, 30, 80, 0, 400, 300, TooltipSpec{}).empty()); // no text height + CHECK(computeTooltip(0, 0, 100, 30, 80, 14, 0, 300, TooltipSpec{}).empty()); // no client width + CHECK(computeTooltip(0, 0, 100, 30, 80, 14, 400, 0, TooltipSpec{}).empty()); // no client height +} + +int main() { + testStripPresent(); + testStripAbsent(); + testStripEmptyPrefix(); + testStripExactMatchLeavesEmpty(); + testStripBetaPrefix(); + testBelowCentred(); + testClampRight(); + testClampLeft(); + testFlipAbove(); + testBothClipClampsBottom(); + testDegenerateEmpty(); + + if (g_fail == 0) std::printf("tooltip: all tests passed\n"); + else std::printf("tooltip: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}