From c0f61d0472441870d57cf6876b0c12af491eab11 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 6 Jun 2026 21:21:11 -0700 Subject: [PATCH] feat: scrollbar thumb travel math + UI tweaks - Scrollbars: proper track-travel geometry so a min-size thumb stays inside the track and dragging maps 1:1 to the cursor at any zoom; clicking the empty track jumps the view to re-center the thumb under the cursor. Rounded thumbs with hover/active highlight; taller track. - Spectrum slice (PSD) always spans the full frequency range; ignoring the selection's freq bounds avoids cropping bins and skewing the auto-ranged dB axis and peak pick. Selection rect still drawn normally. - Default annotation overlay opacity 0.06 -> 0.24 (quiet but legible). Co-Authored-By: Claude Opus 4.8 --- src/render.c | 10 +++-- src/spectrogram.c | 99 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/src/render.c b/src/render.c index 544a1b4..22e382b 100644 --- a/src/render.c +++ b/src/render.c @@ -714,9 +714,13 @@ void DrawSpectrumPanel(Rectangle bounds) app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f); float t0 = hasSel ? app.sel.timeStart : app.view.start; float t1 = hasSel ? app.sel.timeEnd : app.view.end; - float f0 = hasSel ? app.sel.freqStart : app.view.freqStart; - float f1 = hasSel ? app.sel.freqEnd : app.view.freqEnd; - if (f1 < f0) { float t = f0; f0 = f1; f1 = t; } + // The spectrum slice always spans the complete frequency range. Honoring a + // partial frequency selection only crops the displayed bins and skews the + // auto-ranged dB axis + peak pick (inaccurate bins), so we ignore the + // selection's freq bounds here. The selection rectangle is still drawn + // normally by DrawSelection(); we just don't filter the slice by frequency. + float f0 = 0.0f; + float f1 = 1.0f; int maxBins = FFT_SIZE_MAX / 2 + 1; float* power = (float*)malloc(maxBins * sizeof(float)); diff --git a/src/spectrogram.c b/src/spectrogram.c index 9c225f6..ef872e3 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -142,7 +142,7 @@ static Layout ComputeLayout(void) L.scale = GetUIScale(); L.sidebarWidth = 320 * L.scale; L.labelHeight = 15 * L.scale; - L.scrollbarHeight = 18 * L.scale; + L.scrollbarHeight = 22 * L.scale; L.freqLabelWidth = 65 * L.scale; L.vScrollbarWidth = 18 * L.scale; L.topMargin = 50 * L.scale; @@ -827,7 +827,7 @@ int main(int argc, char* argv[]) app.displayMaxFreqHz = 0.0f; // 0 = no crop; user sets via sidebar slider app.showAnnotations = true; app.annotationsExpanded = false; - app.annotationOpacityBase = 0.06f; // whisper-faint by default — signal wins + app.annotationOpacityBase = 0.24f; // quiet but legible by default — signal still wins app.annotationOpacityHover = 0.65f; // pop on hover / selection // Optional CLI override of the resting overlay alpha (e.g. for a brighter // GUI default). The headless render path sets its own default separately. @@ -1584,57 +1584,80 @@ int main(int argc, char* argv[]) viewBounds, (Vector2){ 0, 0 }, 0.0f, WHITE); } - // Draw scrollbars - // Horizontal scrollbar (time) - DrawRectangleRec(hScrollbar, DARKGRAY); - float hThumbWidth = (app.view.end - app.view.start) * hScrollbar.width; - float hThumbX = hScrollbar.x + app.view.start * hScrollbar.width; - if (hThumbWidth < 10) hThumbWidth = 10; - DrawRectangle(hThumbX, hScrollbar.y, hThumbWidth, hScrollbar.height, GRAY); - - // Vertical scrollbar (frequency) - DrawRectangleRec(vScrollbar, DARKGRAY); - float vThumbHeight = (app.view.freqEnd - app.view.freqStart) * vScrollbar.height; - float vThumbY = vScrollbar.y + (1.0f - app.view.freqEnd) * vScrollbar.height; - if (vThumbHeight < 10) vThumbHeight = 10; - DrawRectangle(vScrollbar.x, vThumbY, vScrollbar.width, vThumbHeight, GRAY); + // Draw scrollbars. Thumb geometry uses proper "track travel" math + // (thumb travels over width-thumb, not the whole track) so a min-size + // thumb stays inside the track and dragging maps 1:1 to the cursor at + // any zoom. minThumb keeps the thumb grabbable when zoomed way in. + Vector2 mouse = GetMousePosition(); + float minThumb = 28.0f * renderScale; + Color trackColor = (Color){ 40, 40, 46, 255 }; + Color trackBorder = (Color){ 72, 72, 82, 255 }; + Color thumbColor = (Color){ 110, 114, 126, 255 }; + Color thumbHot = (Color){ 158, 164, 180, 255 }; - // Handle scrollbar dragging + // Horizontal scrollbar (time) + float hViewW = app.view.end - app.view.start; + float hThumbW = fmaxf(hViewW * hScrollbar.width, minThumb); + float hTravel = hScrollbar.width - hThumbW; + float hDenom = 1.0f - hViewW; + float hThumbX = hScrollbar.x + (hDenom > 1e-6f ? (app.view.start / hDenom) * hTravel : 0.0f); + Rectangle hThumb = { hThumbX, hScrollbar.y, hThumbW, hScrollbar.height }; + + // Vertical scrollbar (frequency; axis flipped so freqEnd=1 is at top) + float vViewH = app.view.freqEnd - app.view.freqStart; + float vThumbH = fmaxf(vViewH * vScrollbar.height, minThumb); + float vTravel = vScrollbar.height - vThumbH; + float vDenom = 1.0f - vViewH; + float vThumbY = vScrollbar.y + (vDenom > 1e-6f ? ((1.0f - app.view.freqEnd) / vDenom) * vTravel : 0.0f); + Rectangle vThumb = { vScrollbar.x, vThumbY, vScrollbar.width, vThumbH }; + + // Handle scrollbar interaction. A press on the empty track jumps the + // view so the thumb re-centers under the cursor, then drags from there. static bool draggingH = false, draggingV = false; static Vector2 dragStartPos; static float dragStartViewStart, dragStartFreqViewStart; - - if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), hScrollbar)) { + + if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(mouse, hScrollbar)) { + if (!CheckCollisionPointRec(mouse, hThumb) && hTravel > 0.0f) { + float frac = Clamp((mouse.x - hScrollbar.x - hThumbW * 0.5f) / hTravel, 0.0f, 1.0f); + app.view.start = frac * hDenom; + app.view.end = app.view.start + hViewW; + app.visibleTextureValid = false; + } draggingH = true; - dragStartPos = GetMousePosition(); + dragStartPos = mouse; dragStartViewStart = app.view.start; } - if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), vScrollbar)) { + if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(mouse, vScrollbar)) { + if (!CheckCollisionPointRec(mouse, vThumb) && vTravel > 0.0f) { + float frac = Clamp((mouse.y - vScrollbar.y - vThumbH * 0.5f) / vTravel, 0.0f, 1.0f); + app.view.freqEnd = 1.0f - frac * vDenom; + app.view.freqStart = app.view.freqEnd - vViewH; + app.visibleTextureValid = false; + } draggingV = true; - dragStartPos = GetMousePosition(); + dragStartPos = mouse; dragStartFreqViewStart = app.view.freqStart; } if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { - float dx = (GetMousePosition().x - dragStartPos.x) / hScrollbar.width; - float viewWidth = app.view.end - app.view.start; + float dx = (hTravel > 0.0f) ? ((mouse.x - dragStartPos.x) / hTravel) * hDenom : 0.0f; app.view.start = dragStartViewStart + dx; - app.view.end = app.view.start + viewWidth; - if (app.view.start < 0) { app.view.start = 0; app.view.end = viewWidth; } - if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - viewWidth; } + app.view.end = app.view.start + hViewW; + if (app.view.start < 0) { app.view.start = 0; app.view.end = hViewW; } + if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - hViewW; } app.visibleTextureValid = false; } if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { - float dy = (GetMousePosition().y - dragStartPos.y) / vScrollbar.height; - float freqWidth = app.view.freqEnd - app.view.freqStart; + float dy = (vTravel > 0.0f) ? ((mouse.y - dragStartPos.y) / vTravel) * vDenom : 0.0f; app.view.freqStart = dragStartFreqViewStart - dy; - app.view.freqEnd = app.view.freqStart + freqWidth; + app.view.freqEnd = app.view.freqStart + vViewH; if (app.view.freqStart < 0) { app.view.freqStart = 0; - app.view.freqEnd = fminf(freqWidth, 1.0f); + app.view.freqEnd = fminf(vViewH, 1.0f); } if (app.view.freqEnd > 1) { app.view.freqEnd = 1; - app.view.freqStart = fmaxf(1.0f - freqWidth, 0.0f); + app.view.freqStart = fmaxf(1.0f - vViewH, 0.0f); } if (app.view.freqStart < 0) app.view.freqStart = 0; if (app.view.freqEnd > 1) app.view.freqEnd = 1; @@ -1642,6 +1665,18 @@ int main(int argc, char* argv[]) } if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; } + // Draw tracks + thumbs after interaction so hover/active state and the + // jumped position are reflected this frame. + DrawRectangleRec(hScrollbar, trackColor); + DrawRectangleLinesEx(hScrollbar, 1.0f, trackBorder); + DrawRectangleRounded(hThumb, 0.5f, 4, + (draggingH || CheckCollisionPointRec(mouse, hThumb)) ? thumbHot : thumbColor); + + DrawRectangleRec(vScrollbar, trackColor); + DrawRectangleLinesEx(vScrollbar, 1.0f, trackBorder); + DrawRectangleRounded(vThumb, 0.5f, 4, + (draggingV || CheckCollisionPointRec(mouse, vThumb)) ? thumbHot : thumbColor); + if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f)); // Timeline lane sits above the spectrogram. Drawn before the // overlays so its hover/selection state is set for the same frame.