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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 21:21:11 -07:00
parent 398be34aaf
commit c0f61d0472
2 changed files with 74 additions and 35 deletions
+7 -3
View File
@@ -714,9 +714,13 @@ void DrawSpectrumPanel(Rectangle bounds)
app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f); app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f);
float t0 = hasSel ? app.sel.timeStart : app.view.start; float t0 = hasSel ? app.sel.timeStart : app.view.start;
float t1 = hasSel ? app.sel.timeEnd : app.view.end; float t1 = hasSel ? app.sel.timeEnd : app.view.end;
float f0 = hasSel ? app.sel.freqStart : app.view.freqStart; // The spectrum slice always spans the complete frequency range. Honoring a
float f1 = hasSel ? app.sel.freqEnd : app.view.freqEnd; // partial frequency selection only crops the displayed bins and skews the
if (f1 < f0) { float t = f0; f0 = f1; f1 = t; } // 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; int maxBins = FFT_SIZE_MAX / 2 + 1;
float* power = (float*)malloc(maxBins * sizeof(float)); float* power = (float*)malloc(maxBins * sizeof(float));
+64 -29
View File
@@ -142,7 +142,7 @@ static Layout ComputeLayout(void)
L.scale = GetUIScale(); L.scale = GetUIScale();
L.sidebarWidth = 320 * L.scale; L.sidebarWidth = 320 * L.scale;
L.labelHeight = 15 * L.scale; L.labelHeight = 15 * L.scale;
L.scrollbarHeight = 18 * L.scale; L.scrollbarHeight = 22 * L.scale;
L.freqLabelWidth = 65 * L.scale; L.freqLabelWidth = 65 * L.scale;
L.vScrollbarWidth = 18 * L.scale; L.vScrollbarWidth = 18 * L.scale;
L.topMargin = 50 * 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.displayMaxFreqHz = 0.0f; // 0 = no crop; user sets via sidebar slider
app.showAnnotations = true; app.showAnnotations = true;
app.annotationsExpanded = false; 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 app.annotationOpacityHover = 0.65f; // pop on hover / selection
// Optional CLI override of the resting overlay alpha (e.g. for a brighter // Optional CLI override of the resting overlay alpha (e.g. for a brighter
// GUI default). The headless render path sets its own default separately. // 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); viewBounds, (Vector2){ 0, 0 }, 0.0f, WHITE);
} }
// Draw scrollbars // 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 };
// Horizontal scrollbar (time) // Horizontal scrollbar (time)
DrawRectangleRec(hScrollbar, DARKGRAY); float hViewW = app.view.end - app.view.start;
float hThumbWidth = (app.view.end - app.view.start) * hScrollbar.width; float hThumbW = fmaxf(hViewW * hScrollbar.width, minThumb);
float hThumbX = hScrollbar.x + app.view.start * hScrollbar.width; float hTravel = hScrollbar.width - hThumbW;
if (hThumbWidth < 10) hThumbWidth = 10; float hDenom = 1.0f - hViewW;
DrawRectangle(hThumbX, hScrollbar.y, hThumbWidth, hScrollbar.height, GRAY); 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) // Vertical scrollbar (frequency; axis flipped so freqEnd=1 is at top)
DrawRectangleRec(vScrollbar, DARKGRAY); float vViewH = app.view.freqEnd - app.view.freqStart;
float vThumbHeight = (app.view.freqEnd - app.view.freqStart) * vScrollbar.height; float vThumbH = fmaxf(vViewH * vScrollbar.height, minThumb);
float vThumbY = vScrollbar.y + (1.0f - app.view.freqEnd) * vScrollbar.height; float vTravel = vScrollbar.height - vThumbH;
if (vThumbHeight < 10) vThumbHeight = 10; float vDenom = 1.0f - vViewH;
DrawRectangle(vScrollbar.x, vThumbY, vScrollbar.width, vThumbHeight, GRAY); 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 dragging // 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 bool draggingH = false, draggingV = false;
static Vector2 dragStartPos; static Vector2 dragStartPos;
static float dragStartViewStart, dragStartFreqViewStart; 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; draggingH = true;
dragStartPos = GetMousePosition(); dragStartPos = mouse;
dragStartViewStart = app.view.start; 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; draggingV = true;
dragStartPos = GetMousePosition(); dragStartPos = mouse;
dragStartFreqViewStart = app.view.freqStart; dragStartFreqViewStart = app.view.freqStart;
} }
if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - dragStartPos.x) / hScrollbar.width; float dx = (hTravel > 0.0f) ? ((mouse.x - dragStartPos.x) / hTravel) * hDenom : 0.0f;
float viewWidth = app.view.end - app.view.start;
app.view.start = dragStartViewStart + dx; app.view.start = dragStartViewStart + dx;
app.view.end = app.view.start + viewWidth; app.view.end = app.view.start + hViewW;
if (app.view.start < 0) { app.view.start = 0; app.view.end = viewWidth; } 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 - viewWidth; } if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - hViewW; }
app.visibleTextureValid = false; app.visibleTextureValid = false;
} }
if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dy = (GetMousePosition().y - dragStartPos.y) / vScrollbar.height; float dy = (vTravel > 0.0f) ? ((mouse.y - dragStartPos.y) / vTravel) * vDenom : 0.0f;
float freqWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqStart = dragStartFreqViewStart - dy; app.view.freqStart = dragStartFreqViewStart - dy;
app.view.freqEnd = app.view.freqStart + freqWidth; app.view.freqEnd = app.view.freqStart + vViewH;
if (app.view.freqStart < 0) { if (app.view.freqStart < 0) {
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) { if (app.view.freqEnd > 1) {
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.freqStart < 0) app.view.freqStart = 0;
if (app.view.freqEnd > 1) app.view.freqEnd = 1; 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; } 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)); if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
// Timeline lane sits above the spectrogram. Drawn before the // Timeline lane sits above the spectrogram. Drawn before the
// overlays so its hover/selection state is set for the same frame. // overlays so its hover/selection state is set for the same frame.