refactor: group viewport/zoom/pan state into a Viewport sub-struct

The visible-window + pan-anchor state was 10 flat fields (viewStart, viewEnd,
freqView*, pan*) used densely throughout the zoom/pan/scrollbar code. Group
them into a Viewport sub-struct (app.view.*).

Rename anchored strictly to 'app.' so ScopeView's own view->viewStart/viewEnd
(same field names) are untouched. Settled render verified pixel-identical
(AE=0); End/zoom path verified via injected key input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 09:17:26 -07:00
parent 8e4250ae63
commit f91cae77e8
3 changed files with 125 additions and 123 deletions
+95 -95
View File
@@ -119,7 +119,7 @@ void ResetForNewSignal(void)
FreeAllCacheEntries(&app.fftCache);
// Reset view + selection to full range.
app.viewStart = 0.0f; app.viewEnd = 1.0f;
app.view.start = 0.0f; app.view.end = 1.0f;
ClearSelection();
// Invalidate the cached visible texture.
@@ -142,15 +142,15 @@ static void ActionExport(void) { ExportPNG(&app, app.exportDir); }
static void ActionResetView(void)
{
app.viewStart = 0.0f; app.viewEnd = 1.0f;
app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f;
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
}
static void ActionZoomToStart(void)
{
app.viewStart = 0.0f;
app.viewEnd = 0.1f;
app.view.start = 0.0f;
app.view.end = 0.1f;
app.visibleTextureValid = false;
}
@@ -224,8 +224,8 @@ int main(int argc, char* argv[])
app.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f;
app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f;
app.viewStart = 0.0f; app.viewEnd = 1.0f;
app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f;
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.showGrid = true;
app.colormap = COLORMAP_INFERNO;
app.amplitudeMode = SCALE_RELATIVE;
@@ -359,31 +359,31 @@ int main(int argc, char* argv[])
// --- Time axis zoom (around cursor X) ---
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
mouseT = app.viewStart + mouseT * (app.viewEnd - app.viewStart);
float viewWidth = app.viewEnd - app.viewStart;
mouseT = app.view.start + mouseT * (app.view.end - app.view.start);
float viewWidth = app.view.end - app.view.start;
float newWidth = viewWidth * zoomFactor;
if (newWidth < 0.02f) newWidth = 0.02f;
if (newWidth > 1.0f) newWidth = 1.0f;
float leftOfMouse = mouseT - app.viewStart;
float rightOfMouse = app.viewEnd - mouseT;
app.viewStart = mouseT - leftOfMouse * (newWidth / viewWidth);
app.viewEnd = mouseT + rightOfMouse * (newWidth / viewWidth);
if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = newWidth; }
if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - newWidth; }
float leftOfMouse = mouseT - app.view.start;
float rightOfMouse = app.view.end - mouseT;
app.view.start = mouseT - leftOfMouse * (newWidth / viewWidth);
app.view.end = mouseT + rightOfMouse * (newWidth / viewWidth);
if (app.view.start < 0) { app.view.start = 0; app.view.end = newWidth; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - newWidth; }
// --- Frequency axis zoom (around cursor Y) ---
float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height;
mouseF = app.freqViewStart + mouseF * (app.freqViewEnd - app.freqViewStart);
float freqWidth = app.freqViewEnd - app.freqViewStart;
mouseF = app.view.freqStart + mouseF * (app.view.freqEnd - app.view.freqStart);
float freqWidth = app.view.freqEnd - app.view.freqStart;
float newFreqWidth = freqWidth * zoomFactor;
if (newFreqWidth < 0.001f) newFreqWidth = 0.001f;
float belowMouse = mouseF - app.freqViewStart;
float aboveMouse = app.freqViewEnd - mouseF;
app.freqViewStart = mouseF - belowMouse * (newFreqWidth / freqWidth);
app.freqViewEnd = mouseF + aboveMouse * (newFreqWidth / freqWidth);
float belowMouse = mouseF - app.view.freqStart;
float aboveMouse = app.view.freqEnd - mouseF;
app.view.freqStart = mouseF - belowMouse * (newFreqWidth / freqWidth);
app.view.freqEnd = mouseF + aboveMouse * (newFreqWidth / freqWidth);
// Clamp to physical frequency limits [0, 1] — can't see beyond Nyquist or below 0 Hz
if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = fminf(app.freqViewEnd, 1.0f); }
if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = fmaxf(app.freqViewStart, 0.0f); }
if (app.view.freqStart < 0) { app.view.freqStart = 0; app.view.freqEnd = fminf(app.view.freqEnd, 1.0f); }
if (app.view.freqEnd > 1) { app.view.freqEnd = 1; app.view.freqStart = fmaxf(app.view.freqStart, 0.0f); }
// Invalidate texture cache
app.visibleTextureValid = false;
@@ -393,51 +393,51 @@ int main(int argc, char* argv[])
// Pan with Alt+drag or middle mouse button (pans both axes)
bool canPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
if (canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.isPanning = true;
app.panStartPos = GetMousePosition();
app.panStartViewStart = app.viewStart;
app.panStartViewEnd = app.viewEnd;
app.panStartFreqViewStart = app.freqViewStart;
app.panStartFreqViewEnd = app.freqViewEnd;
app.view.isPanning = true;
app.view.panStartPos = GetMousePosition();
app.view.panStart = app.view.start;
app.view.panEnd = app.view.end;
app.view.panFreqStart = app.view.freqStart;
app.view.panFreqEnd = app.view.freqEnd;
}
if (app.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - app.panStartPos.x) / viewBounds.width;
float dy = (GetMousePosition().y - app.panStartPos.y) / viewBounds.height;
float viewWidth = app.panStartViewEnd - app.panStartViewStart;
float freqWidth = app.panStartFreqViewEnd - app.panStartFreqViewStart;
app.viewStart = app.panStartViewStart - dx * viewWidth;
app.viewEnd = app.panStartViewEnd - dx * viewWidth;
if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = viewWidth; }
if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - viewWidth; }
app.freqViewStart = app.panStartFreqViewStart + dy * freqWidth;
app.freqViewEnd = app.panStartFreqViewEnd + dy * freqWidth;
if (app.view.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - app.view.panStartPos.x) / viewBounds.width;
float dy = (GetMousePosition().y - app.view.panStartPos.y) / viewBounds.height;
float viewWidth = app.view.panEnd - app.view.panStart;
float freqWidth = app.view.panFreqEnd - app.view.panFreqStart;
app.view.start = app.view.panStart - dx * viewWidth;
app.view.end = app.view.panEnd - dx * 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.freqStart = app.view.panFreqStart + dy * freqWidth;
app.view.freqEnd = app.view.panFreqEnd + dy * freqWidth;
// Clamp to physical limits [0, 1]
if (app.freqViewStart < 0) {
float actualWidth = app.freqViewEnd - app.freqViewStart;
app.freqViewStart = 0;
app.freqViewEnd = fminf(actualWidth, 1.0f);
if (app.view.freqStart < 0) {
float actualWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqStart = 0;
app.view.freqEnd = fminf(actualWidth, 1.0f);
}
if (app.freqViewEnd > 1) {
float actualWidth = app.freqViewEnd - app.freqViewStart;
app.freqViewEnd = 1;
app.freqViewStart = fmaxf(1.0f - actualWidth, 0.0f);
if (app.view.freqEnd > 1) {
float actualWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqEnd = 1;
app.view.freqStart = fmaxf(1.0f - actualWidth, 0.0f);
}
if (app.freqViewStart < 0) app.freqViewStart = 0;
if (app.freqViewEnd > 1) app.freqViewEnd = 1;
if (app.view.freqStart < 0) app.view.freqStart = 0;
if (app.view.freqEnd > 1) app.view.freqEnd = 1;
app.visibleTextureValid = false;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.isPanning = false;
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.view.isPanning = false;
// Foreground high-res: when user zooms in, compute missing
// segments in the visible range immediately (responsive).
// Background task handles the rest when idle.
if (app.skipFactor > 1 && app.stft.numSegments > 0 && !app.bgFinished) {
float viewRange = app.viewEnd - app.viewStart;
float viewRange = app.view.end - app.view.start;
if (viewRange <= 0.25f) {
// Clamp to valid segment range
int viewStartSeg = (int)(app.viewStart * app.stft.numSegments);
int viewEndSeg = (int)(app.viewEnd * app.stft.numSegments);
int viewStartSeg = (int)(app.view.start * app.stft.numSegments);
int viewEndSeg = (int)(app.view.end * app.stft.numSegments);
if (viewStartSeg < 0) viewStartSeg = 0;
if (viewStartSeg >= app.stft.numSegments) viewStartSeg = app.stft.numSegments - 1;
if (viewEndSeg >= app.stft.numSegments) viewEndSeg = app.stft.numSegments - 1;
@@ -461,7 +461,7 @@ int main(int argc, char* argv[])
// segments at full resolution. Pauses on any interaction.
// Also kicks in when zoomed out (no foreground trigger) to fill
// segments outside the view range.
bool isZoomedIn = (app.skipFactor > 1 && app.viewEnd - app.viewStart <= 0.25f);
bool isZoomedIn = (app.skipFactor > 1 && app.view.end - app.view.start <= 0.25f);
if (app.isBgProcessing && !app.bgFinished && !IsUserInteracting()) {
int endSeg = app.bgHighResSeg + 50; // chunks of 50 segments
if (endSeg > app.stft.numSegments) endSeg = app.stft.numSegments;
@@ -562,10 +562,10 @@ int main(int argc, char* argv[])
bool hoverInsideSelection = false;
if (hasSelection && CheckCollisionPointRec(mousePos, selBounds)) {
// Convert mouse position to signal coordinates
float viewWidth = app.viewEnd - app.viewStart;
float freqWidth = app.freqViewEnd - app.freqViewStart;
float mouseTime = app.viewStart + ((mousePos.x - selBounds.x) / selBounds.width) * viewWidth;
float mouseFreq = app.freqViewStart + (1.0f - (mousePos.y - selBounds.y) / selBounds.height) * freqWidth;
float viewWidth = app.view.end - app.view.start;
float freqWidth = app.view.freqEnd - app.view.freqStart;
float mouseTime = app.view.start + ((mousePos.x - selBounds.x) / selBounds.width) * viewWidth;
float mouseFreq = app.view.freqStart + (1.0f - (mousePos.y - selBounds.y) / selBounds.height) * freqWidth;
if (mouseTime >= app.sel.timeStart && mouseTime <= app.sel.timeEnd &&
mouseFreq >= app.sel.freqStart && mouseFreq <= app.sel.freqEnd) {
@@ -614,17 +614,17 @@ int main(int argc, char* argv[])
float viewportT = (mousePos.x - selBounds.x) / selBounds.width;
float viewportF = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
app.sel.timeStart = Clamp(app.viewStart + viewportT * (app.viewEnd - app.viewStart), 0.0f, 1.0f);
app.sel.timeStart = Clamp(app.view.start + viewportT * (app.view.end - app.view.start), 0.0f, 1.0f);
app.sel.timeEnd = app.sel.timeStart;
app.sel.freqStart = Clamp(app.freqViewStart + viewportF * (app.freqViewEnd - app.freqViewStart), 0.0f, 1.0f);
app.sel.freqStart = Clamp(app.view.freqStart + viewportF * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
app.sel.freqEnd = app.sel.freqStart;
}
}
// Dragging existing selection
if (app.sel.isDragging && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float viewWidth = app.viewEnd - app.viewStart;
float freqWidth = app.freqViewEnd - app.freqViewStart;
float viewWidth = app.view.end - app.view.start;
float freqWidth = app.view.freqEnd - app.view.freqStart;
float dx = (mousePos.x - app.sel.dragStartPos.x) / selBounds.width;
float dy = (mousePos.y - app.sel.dragStartPos.y) / selBounds.height;
@@ -646,8 +646,8 @@ int main(int argc, char* argv[])
float viewportT = (mousePos.x - selBounds.x) / selBounds.width;
float viewportF = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
app.sel.timeEnd = Clamp(app.viewStart + viewportT * (app.viewEnd - app.viewStart), 0.0f, 1.0f);
app.sel.freqEnd = Clamp(app.freqViewStart + viewportF * (app.freqViewEnd - app.freqViewStart), 0.0f, 1.0f);
app.sel.timeEnd = Clamp(app.view.start + viewportT * (app.view.end - app.view.start), 0.0f, 1.0f);
app.sel.freqEnd = Clamp(app.view.freqStart + viewportF * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
@@ -837,13 +837,13 @@ int main(int argc, char* argv[])
int imgHeight = app.spectrogramImage.height;
// Calculate visible region (time and frequency)
int visibleStartX = (int)(app.viewStart * imgWidth);
int visibleEndX = (int)(app.viewEnd * imgWidth);
int visibleStartX = (int)(app.view.start * imgWidth);
int visibleEndX = (int)(app.view.end * imgWidth);
int visibleWidth = visibleEndX - visibleStartX;
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max)
int visibleStartY = (int)((1.0f - app.freqViewEnd) * imgHeight);
int visibleEndY = (int)((1.0f - app.freqViewStart) * imgHeight);
int visibleStartY = (int)((1.0f - app.view.freqEnd) * imgHeight);
int visibleEndY = (int)((1.0f - app.view.freqStart) * imgHeight);
int visibleHeight = visibleEndY - visibleStartY;
// Invalidate cache if view changed or texture not valid
@@ -889,15 +889,15 @@ int main(int argc, char* argv[])
// Draw scrollbars
// Horizontal scrollbar (time)
DrawRectangleRec(hScrollbar, DARKGRAY);
float hThumbWidth = (app.viewEnd - app.viewStart) * hScrollbar.width;
float hThumbX = hScrollbar.x + app.viewStart * hScrollbar.width;
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.freqViewEnd - app.freqViewStart) * vScrollbar.height;
float vThumbY = vScrollbar.y + (1.0f - app.freqViewEnd) * vScrollbar.height;
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);
@@ -909,37 +909,37 @@ int main(int argc, char* argv[])
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), hScrollbar)) {
draggingH = true;
dragStartPos = GetMousePosition();
dragStartViewStart = app.viewStart;
dragStartViewStart = app.view.start;
}
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), vScrollbar)) {
draggingV = true;
dragStartPos = GetMousePosition();
dragStartFreqViewStart = app.freqViewStart;
dragStartFreqViewStart = app.view.freqStart;
}
if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - dragStartPos.x) / hScrollbar.width;
float viewWidth = app.viewEnd - app.viewStart;
app.viewStart = dragStartViewStart + dx;
app.viewEnd = app.viewStart + viewWidth;
if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = viewWidth; }
if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - viewWidth; }
float viewWidth = app.view.end - app.view.start;
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.visibleTextureValid = false;
}
if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dy = (GetMousePosition().y - dragStartPos.y) / vScrollbar.height;
float freqWidth = app.freqViewEnd - app.freqViewStart;
app.freqViewStart = dragStartFreqViewStart - dy;
app.freqViewEnd = app.freqViewStart + freqWidth;
if (app.freqViewStart < 0) {
app.freqViewStart = 0;
app.freqViewEnd = fminf(freqWidth, 1.0f);
float freqWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqStart = dragStartFreqViewStart - dy;
app.view.freqEnd = app.view.freqStart + freqWidth;
if (app.view.freqStart < 0) {
app.view.freqStart = 0;
app.view.freqEnd = fminf(freqWidth, 1.0f);
}
if (app.freqViewEnd > 1) {
app.freqViewEnd = 1;
app.freqViewStart = fmaxf(1.0f - freqWidth, 0.0f);
if (app.view.freqEnd > 1) {
app.view.freqEnd = 1;
app.view.freqStart = fmaxf(1.0f - freqWidth, 0.0f);
}
if (app.freqViewStart < 0) app.freqViewStart = 0;
if (app.freqViewEnd > 1) app.freqViewEnd = 1;
if (app.view.freqStart < 0) app.view.freqStart = 0;
if (app.view.freqEnd > 1) app.view.freqEnd = 1;
app.visibleTextureValid = false;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
@@ -950,8 +950,8 @@ int main(int argc, char* argv[])
DrawPlayhead(viewBounds);
DrawLabels(viewBounds);
float maxFreq = (float)app.signal.sampleRate / 2.0f;
float freqMin = app.freqViewStart * maxFreq;
float freqMax = app.freqViewEnd * maxFreq;
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
@@ -964,8 +964,8 @@ int main(int argc, char* argv[])
app.scopeView.width = viewBounds.width;
app.scopeView.height = (int)scopeHeight;
// Keep time view in sync with spectrogram view
app.scopeView.viewStart = app.viewStart;
app.scopeView.viewEnd = app.viewEnd;
app.scopeView.viewStart = app.view.start;
app.scopeView.viewEnd = app.view.end;
// Update waveform data
app.scopeView.data.samples = app.signal.samples;
app.scopeView.data.numSamples = app.signal.numSamples;