diff --git a/src/render.c b/src/render.c index 7b31c9e..1ab8909 100644 --- a/src/render.c +++ b/src/render.c @@ -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]; - } + 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); diff --git a/src/spectrogram.c b/src/spectrogram.c index b49a043..59a69e8 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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 diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index cc824f9..7b46f47 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -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)