From 98e42b0a6a523f6622eb929b6f015c1e5601e581 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 12 Aug 2026 00:39:27 -0700 Subject: [PATCH] feat: report every annotation under the cursor, not just the topmost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hover hit-test collected its result into a single int, and each layer assigned unconditionally, so the last box drawn won and everything beneath it was silently dropped. With multiple stations transmitting concurrently that hid most of what was there: co-located frames in the same band are indistinguishable on screen, and the tooltip would name exactly one of them with no indication the others existed. Collect the hits into a stack during the same draw pass (the collision math was already there, only the result was being discarded). When more than one box is under the cursor the tooltip becomes a list — one row per event, topmost-first, each with its own colour swatch so a row maps back to a box on screen. Rows lead with node / frame / seq / ch, which is what actually separates overlapping transmissions. Depth is capped at MAX_HOVER_STACK with a "+N more" count so a deep pile can't cover the spectrogram; a single hit still gets the full-detail tooltip as before. Clicking a stack pins the list so it can be read without holding the cursor still — otherwise the tooltip vanishes the moment you move toward it. Click again or Esc to release. A pinned stack is a frozen snapshot, so live hover does not overwrite it; it is dropped when the annotation overlay is hidden or a new file is loaded, both of which would leave it pointing at events that no longer exist. Clicking stacked annotations pins rather than clearing the selection. A click on empty space or a lone box clears as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN --- src/render.c | 139 ++++++++++++++++++++++++++++++++++++++-- src/spectrogram.c | 16 +++++ src/spectrogram_types.h | 16 +++++ 3 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/render.c b/src/render.c index 4f56f18..8a7e4c5 100644 --- a/src/render.c +++ b/src/render.c @@ -1022,6 +1022,96 @@ static void DrawTooltip(Rectangle bounds, Vector2 anchor, DrawTextScaled(lines[i], bx + padX, by + padY + i * lineH, fontSize, LIGHTGRAY); } +// One-line summary of an event, for the stacked-hover list. Leads with the +// fields that actually distinguish co-located transmissions (node, frame, ch); +// the full per-field breakdown stays in the single-event tooltip. +static void BuildEventSummary(const MlnlEvent* e, char* out, int cap) +{ + char who[32] = ""; + if (e->has_node) snprintf(who, sizeof(who), "node %u ", e->node); + + const char* what = e->has_frame ? e->frame : + e->has_command ? e->command : + e->has_label ? e->label : + e->has_name ? e->name : e->kindStr; + + char pos[24] = ""; + if (e->has_seqn && e->nFrames > 1) + snprintf(pos, sizeof(pos), " %d/%d", e->seq + 1, e->nFrames); + + char chs[24] = ""; + if (e->has_ch) snprintf(chs, sizeof(chs), " [%s]", e->ch); + + snprintf(out, cap, "%s%.28s%s%s", who, what, pos, chs); +} + +// Tooltip listing every annotation the cursor is inside, one row per event. +// Rows are drawn topmost-first (reverse of draw order) so the box visually on +// top is the first thing read. Each row gets its event's own colour swatch, +// which is what lets a row be matched back to a specific box on screen. +static void DrawHoverStackTooltip(Rectangle bounds, Vector2 anchor, int total) +{ + int n = app.hoverStackCount; + if (n <= 0) return; + + float scale = GetUIScale(); + float fontSize = 11.0f; + float lineH = fontSize * scale + 4; + float padX = 10 * scale; + float padY = 6 * scale; + float swatch = fontSize * scale * 0.7f; + float swatchGap = swatch + 6 * scale; + + char hdr[64]; + if (total > n) snprintf(hdr, sizeof(hdr), "%d overlapping (+%d more)", total, total - n); + else snprintf(hdr, sizeof(hdr), "%d overlapping", total); + + char rows[MAX_HOVER_STACK][96]; + for (int i = 0; i < n; i++) { + // Reverse: hoverStack is topmost-last, the list reads topmost-first. + const MlnlEvent* e = &app.annotations.events[app.hoverStack[n - 1 - i]]; + BuildEventSummary(e, rows[i], 96); + } + + const char* hint = app.hoverStackPinned ? "click / Esc to unpin" : "click to pin"; + + float maxW = MeasureTextScaled(hdr, fontSize); + float hintW = MeasureTextScaled(hint, fontSize); + if (hintW > maxW) maxW = hintW; + for (int i = 0; i < n; i++) { + float w = MeasureTextScaled(rows[i], fontSize) + swatchGap; + if (w > maxW) maxW = w; + } + + int totalRows = n + 2; // header + rows + hint + int boxW = (int)(maxW + padX * 2); + int boxH = (int)(totalRows * lineH + padY * 2); + float bx = anchor.x + 12, by = anchor.y + 12; + if (bx + boxW > bounds.x + bounds.width) bx = anchor.x - boxW - 12; + if (bx < bounds.x) bx = bounds.x; + if (by + boxH > bounds.y + bounds.height) by = bounds.y + bounds.height - boxH; + if (by < bounds.y) by = bounds.y; + + Color border = app.hoverStackPinned ? (Color){ 255, 220, 120, 255 } : GRAY; + DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 235 }); + DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(border, 0.9f)); + + float y = by + padY; + DrawTextScaled(hdr, bx + padX, y, fontSize, (Color){ 255, 255, 255, 255 }); + y += lineH; + + for (int i = 0; i < n; i++) { + const MlnlEvent* e = &app.annotations.events[app.hoverStack[n - 1 - i]]; + Color c = EventColor(e); + DrawRectangle((int)(bx + padX), (int)(y + 3 * scale), + (int)swatch, (int)swatch, c); + DrawTextScaled(rows[i], bx + padX + swatchGap, y, fontSize, LIGHTGRAY); + y += lineH; + } + + DrawTextScaled(hint, bx + padX, y, fontSize, (Color){ 150, 150, 150, 255 }); +} + static bool IsPointEvent(const MlnlEvent* e) { if (e->t_end > e->t_start) return false; // has a range -> draw as rect @@ -1077,7 +1167,14 @@ static void FormatTxFrameLabel(const MlnlEvent* e, char* out, int cap) void DrawAnnotations(Rectangle bounds) { if (!app.loaded || !app.annotations.loaded) return; - if (!app.showAnnotations) { app.hoveredEvent = -1; return; } + if (!app.showAnnotations) { + // Hiding the overlay must also drop any pinned stack — otherwise the + // panel keeps describing boxes that are no longer drawn. + app.hoveredEvent = -1; + app.hoverStackCount = 0; + app.hoverStackPinned = false; + return; + } if (app.signal.duration <= 0.0f) return; app.hoveredEvent = -1; @@ -1096,6 +1193,12 @@ void DrawAnnotations(Rectangle bounds) int hoverEvent = -1; // hover prefers later layers (assertions over bursts, point markers over both), // which matches the spec's draw-order rationale: things on top win the click. + // hoverEvent stays "the winner" for everything that wants a single event + // (emphasis, selection); the stack below additionally keeps the losers so + // the tooltip can report what the winner is covering up. + int stack[MAX_HOVER_STACK]; + int stackCount = 0; // capped at MAX_HOVER_STACK + int stackTotal = 0; // uncapped, so the tooltip can say "+N more" // ---- Layer 1: tx_burst (wash + outline) ---- // tx_burst uses the full 200 fill multiplier; emphasis comes from a higher @@ -1114,8 +1217,11 @@ void DrawAnnotations(Rectangle bounds) DrawRectangleLinesEx(r, 1.5f, stroke); DrawBoxLabel(r, e->has_note ? e->note : e->kindStr, stroke, /*topInside=*/true); - if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) + if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) { hoverEvent = i; + stackTotal++; + if (stackCount < MAX_HOVER_STACK) stack[stackCount++] = i; + } } // ---- Layer 1b: tx_frame (PRIMARY per-frame fill + outline + label) ---- @@ -1140,8 +1246,11 @@ void DrawAnnotations(Rectangle bounds) FormatTxFrameLabel(e, lbl, sizeof(lbl)); DrawBoxLabel(r, lbl, stroke, /*topInside=*/true); - if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) + if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) { hoverEvent = i; + stackTotal++; + if (stackCount < MAX_HOVER_STACK) stack[stackCount++] = i; + } } // ---- Layer 2: assertion_passed / assertion_failed (outline-dominant; a @@ -1164,8 +1273,11 @@ void DrawAnnotations(Rectangle bounds) DrawBoxLabel(r, e->has_note ? e->note : (e->has_name ? e->name : e->kindStr), stroke, /*topInside=*/false); - if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) + if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) { hoverEvent = i; + stackTotal++; + if (stackCount < MAX_HOVER_STACK) stack[stackCount++] = i; + } } // ---- Layer 3: point markers (vertical lines for zero-width events) ---- @@ -1205,14 +1317,31 @@ void DrawAnnotations(Rectangle bounds) m.x >= x - 4 && m.x <= x + 4 && m.y >= bounds.y && m.y <= bounds.y + bounds.height) { hoverEvent = i; + stackTotal++; + if (stackCount < MAX_HOVER_STACK) stack[stackCount++] = i; } } app.hoveredEvent = hoverEvent; + // Publish the stack unless it's pinned — a pinned stack is a frozen + // snapshot the user is actively reading, so live hover must not clobber it. + if (!app.hoverStackPinned) { + app.hoverStackCount = stackCount; + for (int i = 0; i < stackCount; i++) app.hoverStack[i] = stack[i]; + } + // ---- Tooltip ---- Timeline hover takes priority over spectrogram hover // (the lane is the active surface when you're hovering it). int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent; - if (tipFor >= 0) { + + // A pinned stack outranks both: it's an explicit "show me what's here". + if (app.hoverStackPinned && app.hoverStackCount > 0) { + DrawHoverStackTooltip(bounds, m, stackTotal); + } else if (app.hoverStackCount > 1 && app.hoveredTimelineEvent < 0) { + // Several boxes under the cursor: one line each beats full detail for + // one, since the question being asked is "who else is in here?". + DrawHoverStackTooltip(bounds, m, stackTotal); + } else if (tipFor >= 0) { const MlnlEvent* e = &app.annotations.events[tipFor]; char lines[12][96]; int n = BuildEventLines(e, lines, 12); diff --git a/src/spectrogram.c b/src/spectrogram.c index 556b361..f7a3669 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -232,6 +232,9 @@ void ResetForNewSignal(void) app.hoveredEvent = -1; app.hoveredTimelineEvent = -1; app.selectedAnnotation = -1; + // Indices point into the events array we just freed. + app.hoverStackCount = 0; + app.hoverStackPinned = false; app.autocropPending = true; // run once when this file's STFT is ready } @@ -835,6 +838,8 @@ int main(int argc, char* argv[]) app.timelineExpanded = false; app.hoveredTimelineEvent = -1; app.selectedAnnotation = -1; + app.hoverStackCount = 0; + app.hoverStackPinned = false; for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true; app.showScope = true; app.dividerY = 0.6f; // Start with 60% spectro, 40% scope @@ -1164,6 +1169,10 @@ int main(int argc, char* argv[]) app.showAbout = false; } else if (app.showFileBrowser) { app.showFileBrowser = false; + } else if (app.hoverStackPinned) { + // Release a pinned annotation stack before touching the + // selection — it's the most recently opened thing on screen. + app.hoverStackPinned = false; } else if (app.markerMode && app.marker.active) { // Clear the marker measurement first when the ruler is active. app.marker.active = false; @@ -1344,6 +1353,13 @@ int main(int argc, char* argv[]) app.sel.freqStart = app.sel.freqEnd; app.sel.freqEnd = tmp; } + } else if (app.hoverStackPinned || app.hoverStackCount > 1) { + // A click on stacked annotations pins (or unpins) the + // hit list instead of touching the selection — with + // boxes piled up, "what is under here?" is what the + // click means. Pinning freezes the stack so it can be + // read without holding the cursor perfectly still. + app.hoverStackPinned = !app.hoverStackPinned; } else if (!hoverInsideSelection) { // Sub-threshold drag outside any existing selection: treat // as a click on empty space and reset to full range. A diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index 8e4e69c..298607a 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -30,6 +30,11 @@ #define MAX_SAMPLE_RATE 48000 #define LOUDNESS_FLOOR_DB -80.0f +// How many overlapping annotation boxes the cursor-hit stack retains. Deeper +// piles than this are counted but not listed individually (the tooltip says +// "+N more"), which keeps a dense pile-up from covering the spectrogram. +#define MAX_HOVER_STACK 12 + // Base resolution for proportional UI scaling (see GetUIScale in render.c) #define BASE_WIDTH 1280 #define BASE_HEIGHT 800 @@ -276,6 +281,17 @@ typedef struct { // the overlay drowning the underlying signal. MlnlAnnotations annotations; int hoveredEvent; // spectrogram-cursor hit (-1 = none); used for tooltip + + // Every annotation box the cursor is currently inside, not just the topmost. + // Overlapping transmissions are the normal case (multiple stations on the + // air at once), and a single hit index silently hid everything underneath — + // so the stack is collected during the draw pass and the tooltip reports + // all of it. Topmost-last, matching draw order; hoveredEvent is the last + // entry. Pinning freezes the stack so it can be read without the cursor + // having to stay perfectly still. + int hoverStack[MAX_HOVER_STACK]; + int hoverStackCount; + bool hoverStackPinned; // click-to-pin: survives cursor movement bool showAnnotations; // master on/off bool annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.) bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces)