L5: dock-panel button refinements — overflow menu, tooltips, opposite-mode tag buttons

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.
This commit is contained in:
2026-07-26 23:56:49 -04:00
parent 0c7b871d8d
commit 7cd0771e45
11 changed files with 1025 additions and 53 deletions
+343 -52
View File
@@ -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<int>(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<ActionBarRow> topBarRows() {
std::vector<ActionBarRow> 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<ActionBarRow> 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<ActionBarRow> bottomBarRows() {
const std::string active = activeModeIdOrEmpty();
const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange);
const bool designLive = tagButtonEnabled(active, TagTarget::Design);
std::vector<ActionBarRow> rows;
// Tagging cluster.
rows.push_back({"VIEW_TAG_DESIGN", "Tag Design", ActionCluster::Tagging});
rows.push_back({"VIEW_UNTAG", "Untag", ActionCluster::Tagging});
// Switching cluster.
rows.push_back({"VIEW_ACTIVATE_ARRANGE", "Arrange", ActionCluster::Switching});
rows.push_back({"VIEW_ACTIVATE_DESIGN", "Design", ActionCluster::Switching});
rows.push_back({"VIEW_TOGGLE_MODE", "Toggle", ActionCluster::Switching});
rows.push_back({"VIEW_SHOW_BOTH", "Show Both", ActionCluster::Switching});
// 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<ClusterSpec> actionBarClusters(const std::vector<ActionBarRow>& 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<std::size_t>(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<std::size_t>(hit)]);
const ActionBarRow& row = rows[static_cast<std::size_t>(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<ActionBarRow> 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<int>(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<ClusterSpec> clusters = actionBarClusters(rows);
const std::vector<ActionBarSlot> 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<std::size_t>(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<int>(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<ActionBarRow> 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<unsigned int>(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<int>(rows.size())) {
const int cmd = resolveBarCommandId(rows[static_cast<std::size_t>(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);
}
+21
View File
@@ -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
+39
View File
@@ -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 <string>
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
+42
View File
@@ -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
+87
View File
@@ -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
+50
View File
@@ -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
+62
View File
@@ -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 <string>
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