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)