feat: report every annotation under the cursor, not just the topmost

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
This commit is contained in:
2026-08-12 00:39:27 -07:00
parent 5c3c88dc22
commit 98e42b0a6a
3 changed files with 166 additions and 5 deletions
+16
View File
@@ -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