fix: stop annotation labels overlapping, drop click-to-pin

Every box drew its label unconditionally, so overlapping transmissions
stacked their text into an unreadable smear — several frame names painted
over each other at the same pixels.

DrawBoxLabel now claims a screen-space rect before drawing and skips the
label if it would intersect one already placed this frame. Draw order
decides the winner, so the topmost box keeps its text and the ones beneath
go quiet instead of smearing. The claim is clipped to the box width, so a
long label reserves only what the scissor actually paints rather than
silencing neighbours over space it never uses. Nothing is lost: hovering
still reports every box under the cursor.

The stacked-hover tooltip now sits above the cursor and centred on it,
flipping below only when there is no room. It previously opened down and
to the right, covering the very boxes being described.

Also removes click-to-pin. It was never asked for, it did not reliably
work, and hover alone answers the question it was meant to serve.

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 12:50:30 -07:00
parent 8017954aa1
commit 8026d10547
3 changed files with 49 additions and 40 deletions
+46 -23
View File
@@ -1031,6 +1031,32 @@ static int BuildEventLines(const MlnlEvent* e, char lines[][96], int maxLines)
// topInside=true places the label inside the top of the box (used for tx_bursts
// so the box outline still reads clearly above); false places it just above,
// falling back to inside if the box is at the top of the viewport.
// Label slots already claimed this frame, so overlapping boxes don't stack
// their text into an unreadable smear. Each entry is the screen-space extent
// of a drawn label; a candidate that would collide with one is dropped and
// surfaced on hover instead (the hover stack reports every box under the
// cursor, so nothing is lost — it just isn't painted on top of its neighbour).
#define MAX_LABEL_SLOTS 256
static Rectangle g_labelSlots[MAX_LABEL_SLOTS];
static int g_labelSlotCount = 0;
static void ResetLabelSlots(void) { g_labelSlotCount = 0; }
static bool ClaimLabelSlot(Rectangle r)
{
for (int i = 0; i < g_labelSlotCount; i++) {
// Pure AABB overlap. Labels are single-line and left-aligned, so any
// intersection at all means one would be drawn over the other.
if (r.x < g_labelSlots[i].x + g_labelSlots[i].width &&
r.x + r.width > g_labelSlots[i].x &&
r.y < g_labelSlots[i].y + g_labelSlots[i].height &&
r.y + r.height > g_labelSlots[i].y)
return false;
}
if (g_labelSlotCount < MAX_LABEL_SLOTS) g_labelSlots[g_labelSlotCount++] = r;
return true;
}
static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topInside)
{
if (!text || !*text || box.width < 18.0f) return;
@@ -1040,6 +1066,14 @@ static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topI
int x = (int)box.x + 3;
int y = topInside ? (int)box.y + 2 : (int)(box.y - lineH);
if (y < 0) y = (int)box.y + 2;
// Clip the claim to the box, matching what the scissor actually paints —
// otherwise a long label reserves space it never draws into and needlessly
// suppresses its neighbours.
float drawW = MeasureTextScaled(text, fs);
if (drawW > box.width - 4) drawW = box.width - 4;
if (!ClaimLabelSlot((Rectangle){ (float)x, (float)y, drawW, lineH })) return;
BeginScissorMode(x, y, (int)box.width - 4, (int)lineH);
DrawTextScaled(text, x, y, fs, color);
EndScissorMode();
@@ -1125,28 +1159,28 @@ static void DrawHoverStackTooltip(Rectangle bounds, Vector2 anchor, int total)
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 totalRows = n + 1; // header + rows
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;
// Sit above the cursor, horizontally centred on it: the boxes being
// described are under the pointer, so anything drawn below or beside it
// covers the very thing the user is pointing at. Flips below only when
// there isn't room above.
float bx = anchor.x - boxW * 0.5f;
float by = anchor.y - boxH - 14;
if (by < bounds.y) by = anchor.y + 18;
if (bx < bounds.x) bx = bounds.x;
if (bx + boxW > bounds.x + bounds.width) bx = bounds.x + bounds.width - boxW;
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));
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(GRAY, 0.9f));
float y = by + padY;
DrawTextScaled(hdr, bx + padX, y, fontSize, (Color){ 255, 255, 255, 255 });
@@ -1160,8 +1194,6 @@ static void DrawHoverStackTooltip(Rectangle bounds, Vector2 anchor, int total)
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)
@@ -1220,16 +1252,14 @@ void DrawAnnotations(Rectangle bounds)
{
if (!app.loaded || !app.annotations.loaded) 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;
ResetLabelSlots();
double duration = app.signal.duration;
// Annotation freq mapping uses the DISPLAYED top-of-axis: events with
@@ -1375,21 +1405,14 @@ void DrawAnnotations(Rectangle bounds)
}
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;
// 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) {
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);
-12
View File
@@ -842,7 +842,6 @@ int main(int argc, char* argv[])
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
@@ -1172,10 +1171,6 @@ 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;
@@ -1356,13 +1351,6 @@ 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
+1 -3
View File
@@ -304,11 +304,9 @@ typedef struct {
// 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.
// entry.
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)