// ui.c - file browser, sidebar controls, sliders, and PNG export #include "ui.h" #include "render.h" #include "stft.h" #include "audio.h" #include "platform.h" #include #include #include #include // Internal sidebar widgets (defined after DrawSidebar, which uses them) static void DrawSlider(Rectangle bounds, float value); static bool UpdateSlider(Rectangle bounds, float* value); // True if the rect was left-clicked this frame (hovered + button pressed). static bool Clicked(Rectangle r) { return CheckCollisionPointRec(GetMousePosition(), r) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON); } // The ubiquitous "filled rectangle + 1px border" panel/button chrome. static void DrawPanelBox(Rectangle r, Color fill, Color border) { DrawRectangleRec(r, fill); DrawRectangleLinesEx(r, 1, border); } // ===== File browser ===== void FreeBrowserFiles(void) { if (app.browserFiles) { for (int i = 0; i < app.browserFileCount; i++) free(app.browserFiles[i]); free(app.browserFiles); free(app.browserIsDir); app.browserFiles = NULL; app.browserIsDir = NULL; } app.browserFileCount = 0; } void ScanDirectory(const char* path) { FreeBrowserFiles(); strncpy(app.browserPath, path, sizeof(app.browserPath) - 1); FilePathList files = LoadDirectoryFiles(path); int dirCount = 0, wavCount = 0; for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (DirectoryExists(files.paths[i])) dirCount++; else { const char* ext = GetFileExtension(files.paths[i]); if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) wavCount++; } } int totalCount = dirCount + wavCount; if (totalCount > 0) { app.browserFiles = (char**)malloc(totalCount * sizeof(char*)); app.browserIsDir = (bool*)malloc(totalCount * sizeof(bool)); app.browserFileCount = 0; for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (DirectoryExists(files.paths[i])) { size_t len = strlen(name) + 1; app.browserFiles[app.browserFileCount] = (char*)malloc(len); if (app.browserFiles[app.browserFileCount]) { memcpy(app.browserFiles[app.browserFileCount], name, len); app.browserIsDir[app.browserFileCount] = true; app.browserFileCount++; } } } for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (!DirectoryExists(files.paths[i])) { const char* ext = GetFileExtension(files.paths[i]); if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) { size_t len = strlen(name) + 1; app.browserFiles[app.browserFileCount] = (char*)malloc(len); if (app.browserFiles[app.browserFileCount]) { memcpy(app.browserFiles[app.browserFileCount], name, len); app.browserIsDir[app.browserFileCount] = false; app.browserFileCount++; } } } } } UnloadDirectoryFiles(files); app.browserScroll = 0; app.browserSelected = -1; } static void NavigateToParentDirectory(void) { const char* parent = GetPrevDirectoryPath(app.browserPath); if (parent && strlen(parent) > 0) ScanDirectory(parent); } static void NavigateToDirectory(const char* dirName) { char newPath[512]; int written = snprintf(newPath, sizeof(newPath), "%s/%s", app.browserPath, dirName); if (written < 0 || written >= (int)sizeof(newPath)) return; // Path too long if (DirectoryExists(newPath)) ScanDirectory(newPath); } static void LoadSelectedFile(void) { if (app.browserSelected < 0 || app.browserSelected >= app.browserFileCount) return; char filePath[512]; int written = snprintf(filePath, sizeof(filePath), "%s/%s", app.browserPath, app.browserFiles[app.browserSelected]); if (written < 0 || written >= (int)sizeof(filePath)) return; // Path too long if (app.browserIsDir[app.browserSelected]) { NavigateToDirectory(app.browserFiles[app.browserSelected]); } else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) { ResetForNewSignal(); LoadMlnlFromWav(filePath, &app.annotations); ComputeCollisions(); app.showFileBrowser = false; TraceLog(LOG_INFO, "Loaded: %s", filePath); } } void DrawFileBrowser(void) { // Draw semi-transparent overlay first DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.85f)); float scale = GetUIScale(); float bw = 900.0f * scale, bh = 700.0f * scale; float bx = (GetScreenWidth() - bw) / 2, by = (GetScreenHeight() - bh) / 2; DrawRectangle(bx, by, bw, bh, (Color){ 45, 45, 55, 255 }); DrawRectangleLinesEx((Rectangle){ bx, by, bw, bh }, (int)(2 * scale), GRAY); DrawRectangle(bx, by, bw, (int)(40 * scale), (Color){ 60, 60, 75, 255 }); DrawTextScaled("File Browser - Select WAV File", bx + (int)(15 * scale), by + (int)(8 * scale), (int)(20 * scale), WHITE); // Path bar float pathBarY = by + (int)(46 * scale); DrawRectangle(bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale), (Color){ 30, 30, 40, 255 }); DrawRectangleLinesEx((Rectangle){ bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale) }, (int)(1 * scale), GRAY); char displayPath[300]; strncpy(displayPath, app.browserPath, sizeof(displayPath) - 1); displayPath[sizeof(displayPath) - 1] = '\0'; if (strlen(displayPath) > 60) sprintf(displayPath, "...%s", app.browserPath + strlen(app.browserPath) - 57); DrawTextScaled(displayPath, bx + (int)(22 * scale), pathBarY + (int)(5 * scale), (int)(14 * scale), LIGHTGRAY); // Up button Rectangle upBtn = { bx + bw - (int)(90 * scale), pathBarY, (int)(75 * scale), (int)(30 * scale) }; if (CheckCollisionPointRec(GetMousePosition(), upBtn)) DrawRectangleRec(upBtn, (Color){ 80, 80, 90, 255 }); DrawTextScaled("UP (..)", upBtn.x + (int)(10 * scale), upBtn.y + (int)(7 * scale), (int)(14 * scale), WHITE); // File list float lx = bx + (int)(15 * scale), ly = pathBarY + (int)(40 * scale); float lw = bw - (int)(30 * scale), lh = bh - (int)(195 * scale); DrawRectangle(lx, ly, lw, lh, (Color){ 25, 25, 35, 255 }); DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, (int)(1 * scale), GRAY); // Line height: base 36px scaled (enough for icon + filename without overlap) float lineH = 36 * scale; // Handle empty directory int visibleItems = (int)(lh / lineH); if (visibleItems < 1) visibleItems = 1; if (app.browserFileCount <= 0 || !app.browserFiles) { DrawTextScaled("(No WAV files in directory)", lx + (int)(20 * scale), ly + (int)(lh / 2 - 12 * scale), (int)(14 * scale), GRAY); } else { if (app.browserFileCount > visibleItems) { float sh = (float)visibleItems / app.browserFileCount * lh; if (sh < (int)(10 * scale)) sh = (int)(10 * scale); float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh); DrawRectangle(lx + lw - (int)(10 * scale), sy, (int)(8 * scale), sh, GRAY); } int startItem = app.browserScroll; int endItem = startItem + visibleItems + 1; if (endItem > app.browserFileCount) endItem = app.browserFileCount; float iconW = (int)(45 * scale); // space for icon column for (int i = startItem; i < endItem; i++) { if (i < 0 || i >= app.browserFileCount || !app.browserFiles[i] || !app.browserIsDir) continue; float iy = ly + (i - startItem) * lineH + (int)(2 * scale); bool hovered = CheckCollisionPointRec((Vector2){ GetMouseX(), GetMouseY() }, (Rectangle){ lx + (int)(2 * scale), iy, lw - (int)(14 * scale), lineH - (int)(4 * scale) }); if (i == app.browserSelected) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 50, 70, 120, 180 }); else if (hovered) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 60, 60, 80, 100 }); const char* icon = app.browserIsDir[i] ? "[DIR]" : "[WAV]"; Color iconCol = app.browserIsDir[i] ? (Color){ 255, 220, 80, 255 } : (Color){ 80, 200, 120, 255 }; DrawTextScaled(icon, lx + (int)(8 * scale), iy + (int)(4 * scale), (int)(13 * scale), iconCol); DrawTextScaled(app.browserFiles[i], lx + iconW + (int)(10 * scale), iy + (int)(4 * scale), (int)(14 * scale), WHITE); } } // Scroll with mouse wheel if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && app.browserFileCount > 0) { int wheel = GetMouseWheelMove(); if (wheel > 0) app.browserScroll--; if (wheel < 0) app.browserScroll++; if (app.browserScroll < 0) app.browserScroll = 0; int maxScroll = app.browserFileCount - visibleItems; if (maxScroll < 0) maxScroll = 0; if (app.browserScroll > maxScroll) app.browserScroll = maxScroll; } // Handle clicks if (CheckCollisionPointRec(GetMousePosition(), upBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) NavigateToParentDirectory(); if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && app.browserFileCount > 0) { int clicked = app.browserScroll + (int)((GetMouseY() - ly) / lineH); if (clicked >= 0 && clicked < app.browserFileCount) { app.browserSelected = clicked; static double lastClick = 0; if (GetTime() - lastClick < 0.3) LoadSelectedFile(); lastClick = GetTime(); } } // Buttons float btnY = by + bh - (int)(55 * scale); Rectangle openBtn = { bx + bw - (int)(170 * scale), btnY, (int)(150 * scale), (int)(40 * scale) }; Rectangle cancelBtn = { bx + (int)(15 * scale), btnY, (int)(120 * scale), (int)(40 * scale) }; bool openHovered = CheckCollisionPointRec(GetMousePosition(), openBtn); bool openClicked = openHovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON); if (openHovered) DrawRectangleRec(openBtn, (Color){ 100, 100, 120, 255 }); else DrawRectangleRec(openBtn, (Color){ 80, 80, 90, 255 }); DrawRectangleLinesEx(openBtn, (int)(1 * scale), WHITE); DrawTextScaled("OPEN (Enter)", openBtn.x + (int)(25 * scale), openBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE); DrawRectangleRec(cancelBtn, (Color){ 100, 40, 40, 255 }); DrawTextScaled("ESC Cancel", cancelBtn.x + (int)(18 * scale), cancelBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE); if ((IsKeyPressed(KEY_ENTER) || openClicked) && app.browserSelected >= 0 && app.browserFileCount > 0) LoadSelectedFile(); if (IsKeyPressed(KEY_ESCAPE)) app.showFileBrowser = false; if (IsKeyPressed(KEY_UP) && app.browserSelected > 0 && app.browserFileCount > 0) { app.browserSelected--; if (app.browserSelected < app.browserScroll) app.browserScroll = app.browserSelected; } if (IsKeyPressed(KEY_DOWN) && app.browserSelected < app.browserFileCount - 1 && app.browserFileCount > 0) { app.browserSelected++; if (app.browserSelected >= app.browserScroll + visibleItems) app.browserScroll = app.browserSelected - visibleItems + 1; } } // ===== PNG export ===== void ExportPNG(const SpectrogramApp* spa, const char* dirPath) { if (!spa->stftComputed || !spa->spectrogramImage.data) return; int imgW = spa->spectrogramImage.width; int imgH = spa->spectrogramImage.height; // Selection region in image-pixel coordinates. The image spans the full // Nyquist axis, but sel.freq* are fractions of the *displayed* axis (capped // at EffectiveMaxFreqHz), so scale by DisplayFreqFraction() the same way the // on-screen texture sub-sampling does — otherwise the export grabs a band // higher in frequency than what was box-selected. float cropFrac = DisplayFreqFraction(); int selX0 = (int)(spa->sel.timeStart * imgW); int selX1 = (int)(spa->sel.timeEnd * imgW); int selY0 = (int)((1.0f - spa->sel.freqEnd * cropFrac) * imgH); int selY1 = (int)((1.0f - spa->sel.freqStart * cropFrac) * imgH); // Clamp to image bounds selX0 = Clamp(selX0, 0, imgW); selX1 = Clamp(selX1, 0, imgW); selY0 = Clamp(selY0, 0, imgH); selY1 = Clamp(selY1, 0, imgH); int regionW = selX1 - selX0; int regionH = selY1 - selY0; if (regionW <= 0 || regionH <= 0) return; // Extract selected region by copying pixel rows Image region = { 0 }; region.width = regionW; region.height = regionH; region.mipmaps = 1; region.format = spa->spectrogramImage.format; region.data = RL_MALLOC(regionW * regionH * 4); if (!region.data) return; // Raylib stores images as tightly packed RGBA rows - memcpy each row at a time Color* src = (Color*)spa->spectrogramImage.data; Color* dst = (Color*)region.data; for (int y = 0; y < regionH; y++) { memcpy(dst + y * regionW, src + (selY0 + y) * imgW + selX0, regionW * 4); } // Scale if a non-zero exportScale is set (and region isn't too large) if (spa->exportScale > 0.0f && regionW < 4096) { int outW = regionW * (int)spa->exportScale; int outH = regionH * (int)spa->exportScale; if (outW > 0 && outH > 0) { ImageResize(®ion, outW, outH); } } // Export as PNG char path[4096]; if (spa->exportScale <= 0.0f && regionW == imgW) { // Full width export without scaling snprintf(path, sizeof(path), "%s/spectrogram_full.png", dirPath); } else { snprintf(path, sizeof(path), "%s/spectrogram_export.png", dirPath); } if (ExportImage(region, path)) { Platform_OfferFileToUser(path); // desktop: no-op; web: browser download snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Exported: %dx%d %.40s", region.width, region.height, GetFileName(path)); } else { snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed"); } ((SpectrogramApp*)spa)->exportMessageTimer = 3.0f; RL_FREE(region.data); } // ===== Sidebar ===== // ===== Icons ===== // Drawn from raylib primitives rather than an image atlas or font glyphs: the // bundled font is DejaVu Sans Mono (no symbol coverage), and vector shapes stay // crisp at any UI scale without shipping assets. Each takes the icon's bounding // box and a tint, and draws inside it. static void IconPlay(Rectangle r, Color c) { float p = r.width * 0.22f; DrawTriangle((Vector2){ r.x + p, r.y + p }, (Vector2){ r.x + p, r.y + r.height - p }, (Vector2){ r.x + r.width - p, r.y + r.height * 0.5f }, c); } static void IconStop(Rectangle r, Color c) { float p = r.width * 0.26f; DrawRectangleRec((Rectangle){ r.x + p, r.y + p, r.width - 2 * p, r.height - 2 * p }, c); } // Ruler/measure: a baseline with tick marks. static void IconMarker(Rectangle r, Color c) { float p = r.width * 0.18f; float y0 = r.y + r.height * 0.62f; DrawLineEx((Vector2){ r.x + p, y0 }, (Vector2){ r.x + r.width - p, y0 }, 1.6f, c); for (int i = 0; i < 4; i++) { float tx = r.x + p + (r.width - 2 * p) * (i / 3.0f); float h = (i % 2 == 0) ? r.height * 0.28f : r.height * 0.16f; DrawLineEx((Vector2){ tx, y0 }, (Vector2){ tx, y0 - h }, 1.6f, c); } } // Spectrum slice: a small bar chart / PSD curve. static void IconSpectrum(Rectangle r, Color c) { float p = r.width * 0.18f; float base = r.y + r.height - p; const float hs[5] = { 0.35f, 0.7f, 0.45f, 0.9f, 0.25f }; float bw = (r.width - 2 * p) / 5.0f; for (int i = 0; i < 5; i++) { float h = (r.height - 2 * p) * hs[i]; DrawRectangleRec((Rectangle){ r.x + p + i * bw + 1, base - h, bw - 2, h }, c); } } // Waveform scope: a centered sine-ish trace. static void IconScope(Rectangle r, Color c) { float p = r.width * 0.15f; float midY = r.y + r.height * 0.5f; float w = r.width - 2 * p; Vector2 prev = { r.x + p, midY }; for (int i = 1; i <= 12; i++) { float t = i / 12.0f; Vector2 cur = { r.x + p + w * t, midY - sinf(t * 4.0f * PI) * r.height * 0.28f }; DrawLineEx(prev, cur, 1.4f, c); prev = cur; } } // Grid: two verticals crossing two horizontals. static void IconGrid(Rectangle r, Color c) { float p = r.width * 0.2f; for (int i = 1; i <= 2; i++) { float fx = r.x + p + (r.width - 2 * p) * (i / 3.0f); float fy = r.y + p + (r.height - 2 * p) * (i / 3.0f); DrawLineEx((Vector2){ fx, r.y + p }, (Vector2){ fx, r.y + r.height - p }, 1.2f, c); DrawLineEx((Vector2){ r.x + p, fy }, (Vector2){ r.x + r.width - p, fy }, 1.2f, c); } DrawRectangleLinesEx((Rectangle){ r.x + p, r.y + p, r.width - 2 * p, r.height - 2 * p }, 1.2f, c); } // Select tool: a dashed marquee box with a cursor arrow. static void IconSelect(Rectangle r, Color c) { float p = r.width * 0.2f; Rectangle b = { r.x + p, r.y + p, r.width - 2 * p, r.height - 2 * p }; // Dashed outline, drawn as short segments along each edge. int dashes = 4; float dw = b.width / (dashes * 2.0f - 1.0f); float dh = b.height / (dashes * 2.0f - 1.0f); for (int i = 0; i < dashes; i++) { float ox = b.x + i * dw * 2.0f; float oy = b.y + i * dh * 2.0f; DrawRectangleRec((Rectangle){ ox, b.y, dw, 1.2f }, c); DrawRectangleRec((Rectangle){ ox, b.y + b.height - 1.2f, dw, 1.2f }, c); DrawRectangleRec((Rectangle){ b.x, oy, 1.2f, dh }, c); DrawRectangleRec((Rectangle){ b.x + b.width - 1.2f, oy, 1.2f, dh }, c); } } // Pan/navigate: a four-way arrow. static void IconPan(Rectangle r, Color c) { float cx = r.x + r.width * 0.5f, cy = r.y + r.height * 0.5f; float a = r.width * 0.26f, t = r.width * 0.09f; DrawLineEx((Vector2){ cx - a, cy }, (Vector2){ cx + a, cy }, 1.4f, c); DrawLineEx((Vector2){ cx, cy - a }, (Vector2){ cx, cy + a }, 1.4f, c); DrawTriangle((Vector2){ cx + a + t * 0.6f, cy }, (Vector2){ cx + a - t * 0.3f, cy - t }, (Vector2){ cx + a - t * 0.3f, cy + t }, c); DrawTriangle((Vector2){ cx - a - t * 0.6f, cy }, (Vector2){ cx - a + t * 0.3f, cy + t }, (Vector2){ cx - a + t * 0.3f, cy - t }, c); DrawTriangle((Vector2){ cx, cy - a - t * 0.6f }, (Vector2){ cx - t, cy - a + t * 0.3f }, (Vector2){ cx + t, cy - a + t * 0.3f }, c); DrawTriangle((Vector2){ cx, cy + a + t * 0.6f }, (Vector2){ cx + t, cy + a - t * 0.3f }, (Vector2){ cx - t, cy + a - t * 0.3f }, c); } // Minimap: a frame with a smaller viewport rect inside it. static void IconMinimap(Rectangle r, Color c) { float p = r.width * 0.18f; Rectangle o = { r.x + p, r.y + p * 1.3f, r.width - 2 * p, r.height - 2.6f * p }; DrawRectangleLinesEx(o, 1.2f, c); DrawRectangleRec((Rectangle){ o.x + o.width * 0.14f, o.y + o.height * 0.2f, o.width * 0.4f, o.height * 0.6f }, Fade(c, 0.75f)); } // Timeline lane: a horizontal track with event ticks along it. static void IconTimeline(Rectangle r, Color c) { float p = r.width * 0.16f; Rectangle lane = { r.x + p, r.y + r.height * 0.34f, r.width - 2 * p, r.height * 0.32f }; DrawRectangleLinesEx(lane, 1.2f, c); const float xs[4] = { 0.14f, 0.38f, 0.55f, 0.82f }; for (int i = 0; i < 4; i++) { float tx = lane.x + lane.width * xs[i]; float w = (i % 2 == 0) ? 2.4f : 1.4f; DrawRectangleRec((Rectangle){ tx, lane.y + 1.5f, w, lane.height - 3.0f }, c); } } // Annotations: stacked tag/label boxes. static void IconAnnotations(Rectangle r, Color c) { float p = r.width * 0.18f; float h = (r.height - 2 * p) / 3.2f; for (int i = 0; i < 3; i++) { float w = (r.width - 2 * p) * ((i == 1) ? 0.65f : 1.0f); DrawRectangleLinesEx((Rectangle){ r.x + p, r.y + p + i * h * 1.35f, w, h }, 1.2f, c); } } // Collisions: two overlapping boxes with the intersection filled. static void IconCollision(Rectangle r, Color c) { float p = r.width * 0.17f; float bw = (r.width - 2 * p) * 0.62f; float bh = (r.height - 2 * p) * 0.62f; Rectangle a = { r.x + p, r.y + p, bw, bh }; Rectangle b = { r.x + r.width - p - bw, r.y + r.height - p - bh, bw, bh }; DrawRectangleLinesEx(a, 1.2f, c); DrawRectangleLinesEx(b, 1.2f, c); float ix = fmaxf(a.x, b.x), iy = fmaxf(a.y, b.y); float ix2 = fminf(a.x + a.width, b.x + b.width); float iy2 = fminf(a.y + a.height, b.y + b.height); if (ix2 > ix && iy2 > iy) DrawRectangleRec((Rectangle){ ix, iy, ix2 - ix, iy2 - iy }, Fade(c, 0.85f)); } // Colormap: a horizontal gradient swatch. static void IconColormap(Rectangle r, Color c) { (void)c; float p = r.width * 0.18f; Rectangle sw = { r.x + p, r.y + r.height * 0.3f, r.width - 2 * p, r.height * 0.4f }; int steps = 10; for (int i = 0; i < steps; i++) { Color g = GetColormapColor((float)i / (steps - 1), app.colormap); DrawRectangleRec((Rectangle){ sw.x + sw.width * i / steps, sw.y, sw.width / steps + 1, sw.height }, g); } DrawRectangleLinesEx(sw, 1.0f, (Color){ 160, 160, 170, 255 }); } // FFT size: three bars of increasing height (resolution). static void IconFFT(Rectangle r, Color c) { float p = r.width * 0.2f; float base = r.y + r.height - p; float bw = (r.width - 2 * p) / 3.4f; for (int i = 0; i < 3; i++) { float h = (r.height - 2 * p) * (0.35f + 0.32f * i); DrawRectangleRec((Rectangle){ r.x + p + i * bw * 1.2f, base - h, bw, h }, c); } } // Clear/X. static void IconClear(Rectangle r, Color c) { float p = r.width * 0.28f; DrawLineEx((Vector2){ r.x + p, r.y + p }, (Vector2){ r.x + r.width - p, r.y + r.height - p }, 1.8f, c); DrawLineEx((Vector2){ r.x + r.width - p, r.y + p }, (Vector2){ r.x + p, r.y + r.height - p }, 1.8f, c); } // ===== Menubar ===== // One-shot actions and rarely-touched toggles live here so the sidebar can stay // narrow and hold only what's worth adjusting while watching the spectrogram. // Items reference keymap entries by key code (see MenuItem), so a menu entry // and its keyboard shortcut can't disagree. static const MenuItem FILE_ITEMS[] = { { "Open...", KEY_O, NULL }, { NULL, 0, NULL }, { "Export PNG", KEY_E, NULL }, { "Export WAV", KEY_W, NULL }, }; static const MenuItem VIEW_ITEMS[] = { { "Reset view", KEY_HOME, NULL }, { "Zoom to start", KEY_END, NULL }, { NULL, 0, NULL }, // The rail has no grab handle any more, so this is the way to hide it. { "Hide icon rail", 0, &app.sidebarCollapsed }, { "Fullscreen", KEY_F11, NULL }, }; static const MenuItem ANNO_ITEMS[] = { { "Next collision", KEY_N, NULL }, }; static const MenuItem HELP_ITEMS[] = { { "About / Help", KEY_F1, NULL }, }; #define MENU_COUNT_OF(a) ((int)(sizeof(a) / sizeof((a)[0]))) static const Menu MENUS[] = { { "File", FILE_ITEMS, MENU_COUNT_OF(FILE_ITEMS) }, { "View", VIEW_ITEMS, MENU_COUNT_OF(VIEW_ITEMS) }, { "Annotations", ANNO_ITEMS, MENU_COUNT_OF(ANNO_ITEMS) }, { "Help", HELP_ITEMS, MENU_COUNT_OF(HELP_ITEMS) }, }; const Menu* GetMenus(int* count) { if (count) *count = MENU_COUNT_OF(MENUS); return MENUS; } // Width of a menu's dropdown: the widest label + shortcut pair, floored so a // short menu doesn't render as a sliver. static float MenuPanelWidth(const Menu* m, float scale) { float w = 120 * scale; for (int i = 0; i < m->itemCount; i++) { if (!m->items[i].label) continue; float lw = MeasureTextScaled(m->items[i].label, 12); const char* sc = m->items[i].key ? KeymapLabelFor(m->items[i].key) : ""; if (sc && *sc) lw += MeasureTextScaled(sc, 12) + 28 * scale; lw += 24 * scale; if (lw > w) w = lw; } return w; } void DrawMenubar(void) { float scale = GetUIScale(); float h = MENUBAR_HEIGHT * scale; int sw = GetScreenWidth(); DrawRectangle(0, 0, sw, (int)h, (Color){ 38, 38, 46, 255 }); DrawLine(0, (int)h, sw, (int)h, (Color){ 70, 70, 82, 255 }); int n; const Menu* menus = GetMenus(&n); Vector2 m = GetMousePosition(); bool clicked = IsMouseButtonPressed(MOUSE_LEFT_BUTTON); // A click anywhere outside the bar and the open panel closes the menu. Set // here and cleared below if the click actually lands on something. bool clickConsumed = false; float x = 8 * scale; for (int i = 0; i < n; i++) { float tw = MeasureTextScaled(menus[i].title, 12); Rectangle titleR = { x, 0, tw + 18 * scale, h }; bool over = CheckCollisionPointRec(m, titleR); // Click opens/closes; once a menu is open, hovering another switches to // it (standard menubar behaviour — no click needed to traverse). if (over && clicked) { app.openMenu = (app.openMenu == i) ? -1 : i; clickConsumed = true; } else if (over && app.openMenu >= 0 && app.openMenu != i) { app.openMenu = i; } if (app.openMenu == i || over) DrawRectangleRec(titleR, (Color){ 60, 60, 75, 255 }); DrawTextScaled(menus[i].title, titleR.x + 9 * scale, titleR.y + 4 * scale, 12, (app.openMenu == i) ? WHITE : LIGHTGRAY); x += titleR.width; } // Dropdown panel for the open menu. if (app.openMenu >= 0 && app.openMenu < n) { const Menu* mu = &menus[app.openMenu]; float px = 8 * scale; for (int i = 0; i < app.openMenu; i++) px += MeasureTextScaled(menus[i].title, 12) + 18 * scale; float itemH = 20 * scale; float sepH = 7 * scale; float pw = MenuPanelWidth(mu, scale); float ph = 8 * scale; for (int i = 0; i < mu->itemCount; i++) ph += mu->items[i].label ? itemH : sepH; Rectangle panel = { px, h, pw, ph }; if (panel.x + pw > sw) panel.x = sw - pw; DrawRectangleRec(panel, (Color){ 44, 44, 54, 250 }); DrawRectangleLinesEx(panel, 1, (Color){ 90, 90, 105, 255 }); float iy = panel.y + 4 * scale; for (int i = 0; i < mu->itemCount; i++) { const MenuItem* it = &mu->items[i]; if (!it->label) { DrawLine((int)(panel.x + 6 * scale), (int)(iy + sepH * 0.5f), (int)(panel.x + pw - 6 * scale), (int)(iy + sepH * 0.5f), (Color){ 75, 75, 90, 255 }); iy += sepH; continue; } Rectangle itemR = { panel.x, iy, pw, itemH }; bool enabled = it->toggle ? true : KeymapActionEnabled(it->key); bool over = CheckCollisionPointRec(m, itemR); if (over && enabled) DrawRectangleRec(itemR, (Color){ 70, 70, 90, 255 }); // Checkmark column for the toggles, so state is visible at a glance. if (it->toggle && *it->toggle) DrawTextScaled("*", itemR.x + 7 * scale, iy + 3 * scale, 12, (Color){ 150, 220, 150, 255 }); DrawTextScaled(it->label, itemR.x + 18 * scale, iy + 3 * scale, 12, enabled ? LIGHTGRAY : (Color){ 100, 100, 110, 255 }); const char* sc = it->key ? KeymapLabelFor(it->key) : ""; if (sc && *sc) { float scw = MeasureTextScaled(sc, 12); DrawTextScaled(sc, itemR.x + pw - scw - 9 * scale, iy + 3 * scale, 12, (Color){ 120, 120, 135, 255 }); } if (over && clicked && enabled) { if (it->toggle) *it->toggle = !*it->toggle; else InvokeKeymapAction(it->key); app.openMenu = -1; clickConsumed = true; } iy += itemH; } if (clicked && !clickConsumed && !CheckCollisionPointRec(m, panel) && m.y > h) app.openMenu = -1; } if (IsKeyPressed(KEY_ESCAPE) && app.openMenu >= 0) app.openMenu = -1; } // True when the menubar owns the cursor, so the spectrogram doesn't also react // to a click meant for a menu. bool MenubarCapturesMouse(void) { float scale = GetUIScale(); Vector2 m = GetMousePosition(); if (m.y <= MENUBAR_HEIGHT * scale) return true; if (app.openMenu < 0) return false; int n; const Menu* menus = GetMenus(&n); if (app.openMenu >= n) return false; const Menu* mu = &menus[app.openMenu]; float px = 8 * scale; for (int i = 0; i < app.openMenu; i++) px += MeasureTextScaled(menus[i].title, 12) + 18 * scale; float itemH = 20 * scale, sepH = 7 * scale; float ph = 8 * scale; for (int i = 0; i < mu->itemCount; i++) ph += mu->items[i].label ? itemH : sepH; float pw = MenuPanelWidth(mu, scale); if (px + pw > GetScreenWidth()) px = GetScreenWidth() - pw; return CheckCollisionPointRec(m, (Rectangle){ px, MENUBAR_HEIGHT * scale, pw, ph }); } // ===== Sidebar: icon rail ===== // A vertical strip of icon buttons rather than a stack of labelled widgets. // Anything that is a one-shot action or a rarely-touched setting lives in the // menubar; the rail holds the toggles worth reaching for constantly, plus three // popouts for the settings that need more than an on/off (FFT size, colormap + // levels, annotation kinds). typedef enum { RAIL_POP_NONE = 0, RAIL_POP_FFT, RAIL_POP_LEVELS, RAIL_POP_ANNOTATIONS, } RailPopout; static RailPopout g_railPopout = RAIL_POP_NONE; static float g_railPopoutY = 0.0f; // top of the button that opened it // Deferred rail tooltip: set by RailButton during the early sidebar pass and // drawn in the late pass so it floats above the axis labels and spectrogram. static const char* g_railTipText = NULL; static float g_railTipX = 0.0f, g_railTipY = 0.0f; typedef void (*IconFn)(Rectangle, Color); // One rail entry's button. Returns true if clicked this frame. static bool RailButton(Rectangle r, IconFn icon, bool active, bool enabled, const char* tip, float scale) { Vector2 m = GetMousePosition(); bool over = enabled && CheckCollisionPointRec(m, r); Color fill = active ? (Color){ 55, 80, 110, 255 } : over ? (Color){ 58, 58, 70, 255 } : (Color){ 44, 44, 52, 255 }; DrawRectangleRec(r, fill); DrawRectangleLinesEx(r, 1, active ? (Color){ 120, 180, 240, 255 } : (Color){ 70, 70, 84, 255 }); Color tint = !enabled ? (Color){ 90, 90, 100, 255 } : active ? (Color){ 190, 225, 255, 255 } : (Color){ 200, 200, 210, 255 }; icon(r, tint); // Tooltip text is recorded, not drawn: DrawSidebar runs early (the layout // needs the rail width), so anything drawn here is painted over by the // frequency labels and spectrogram. DrawSidebarPopouts emits it later. if (over && tip) { g_railTipText = tip; g_railTipY = r.y + r.height * 0.5f; g_railTipX = r.x + r.width; } return over && IsMouseButtonPressed(MOUSE_LEFT_BUTTON); } // Popout panel anchored to the right of the rail, opened by a rail button. // Returns its rect so the caller can lay widgets out inside it. static Rectangle RailPopoutPanel(float railW, float y, float w, float h, float scale) { float px = railW + 4 * scale; float py = y; if (py + h > GetScreenHeight()) py = GetScreenHeight() - h - 4 * scale; if (py < MENUBAR_HEIGHT * scale) py = MENUBAR_HEIGHT * scale; Rectangle panel = { px, py, w, h }; DrawRectangleRec(panel, (Color){ 44, 44, 54, 250 }); DrawRectangleLinesEx(panel, 1, (Color){ 100, 100, 118, 255 }); return panel; } // The rail is a fixed-width strip of uniform icon buttons, so there is nothing // to resize and no grab handle to draw. What remains is a plain gap between the // rail and the frequency labels — drawing a visible divider there just stole // width the labels wanted. static void HandleSidebarSplitter(void) { if (app.sidebarCollapsed) return; float scale = GetUIScale(); float w = app.sidebarWidth * scale; // Thin seam so the rail reads as its own surface; the rest of the old // splitter's width now belongs to the label gutter. DrawLine((int)w, (int)(MENUBAR_HEIGHT * scale), (int)w, GetScreenHeight(), (Color){ 55, 55, 65, 255 }); } // True when the cursor is over the rail or an open popout, so the spectrogram // doesn't also act on a click meant for a sidebar control. bool SidebarCapturesMouse(void) { float scale = GetUIScale(); Vector2 m = GetMousePosition(); float railW = app.sidebarCollapsed ? 0.0f : app.sidebarWidth * scale; if (!app.sidebarCollapsed && m.x <= railW) return true; if (g_railPopout == RAIL_POP_NONE) return false; // Popout geometry mirrors the panels drawn in DrawSidebar; generous bounds // are fine here since this only suppresses spectrogram interaction. float w = (g_railPopout == RAIL_POP_FFT) ? 168 * scale : (g_railPopout == RAIL_POP_LEVELS) ? 200 * scale : 190 * scale; float h = (g_railPopout == RAIL_POP_FFT) ? 96 * scale : (g_railPopout == RAIL_POP_LEVELS) ? 208 * scale : 320 * scale; float py = g_railPopoutY; if (py + h > GetScreenHeight()) py = GetScreenHeight() - h - 4 * scale; if (py < MENUBAR_HEIGHT * scale) py = MENUBAR_HEIGHT * scale; return CheckCollisionPointRec(m, (Rectangle){ railW + 4 * scale, py, w, h }); } void DrawSidebar(void) { g_railTipText = NULL; HandleSidebarSplitter(); if (app.sidebarCollapsed) { g_railPopout = RAIL_POP_NONE; return; } float scale = GetUIScale(); float railW = app.sidebarWidth * scale; bool needsRegen = false; DrawRectangle(0, 0, (int)railW, GetScreenHeight(), (Color){ 35, 35, 40, 255 }); // Square button sized to the rail, capped so a widened rail centres the // icons rather than stretching them. float btn = fminf(railW - RAIL_ICON_MARGIN * 2 * scale, RAIL_ICON_SIZE * scale); float bx = (railW - btn) * 0.5f; float y = MENUBAR_HEIGHT * scale + 8 * scale; float gap = 5 * scale; bool loaded = app.loaded; bool playing = app.isPlaying; // --- Transport --- Rectangle rPlay = { bx, y, btn, btn }; if (RailButton(rPlay, playing ? IconStop : IconPlay, playing, loaded, playing ? "Stop (Space)" : "Play selection (Space)", scale)) { if (playing && AudioPlaybackSound.frameCount > 0) { StopSound(AudioPlaybackSound); app.isPlaying = false; app.playbackFinished = false; app.playheadElapsed = 0; app.playheadT = 0; } else { PlaySelectedRegion(); app.isPlaying = true; } } y += btn + gap; Rectangle rClear = { bx, y, btn, btn }; if (RailButton(rClear, IconClear, false, loaded, "Clear selection (Esc)", scale)) ClearSelection(); y += btn + gap * 2.4f; // --- Gesture mode --- what a bare left-drag does on the spectrogram. // Ctrl always means "the other one", so this only sets the default. Rectangle rPan = { bx, y, btn, btn }; if (RailButton(rPan, IconPan, !app.selectMode, loaded, "Pan mode: drag to move (Ctrl+drag selects)", scale)) app.selectMode = false; y += btn + gap; Rectangle rSelect = { bx, y, btn, btn }; if (RailButton(rSelect, IconSelect, app.selectMode, loaded, "Select mode: drag to box-select (Ctrl+drag pans)", scale)) app.selectMode = true; y += btn + gap * 2.4f; // --- Analysis tools --- Rectangle rMarker = { bx, y, btn, btn }; if (RailButton(rMarker, IconMarker, app.markerMode, loaded, "Marker / ruler (M)", scale)) app.markerMode = !app.markerMode; y += btn + gap; Rectangle rSpec = { bx, y, btn, btn }; if (RailButton(rSpec, IconSpectrum, app.showSpectrum, loaded, "Spectrum slice (S)", scale)) app.showSpectrum = !app.showSpectrum; y += btn + gap; Rectangle rScope = { bx, y, btn, btn }; if (RailButton(rScope, IconScope, app.showScope, loaded, "Waveform scope (P)", scale)) app.showScope = !app.showScope; y += btn + gap; Rectangle rGrid = { bx, y, btn, btn }; if (RailButton(rGrid, IconGrid, app.showGrid, true, "Grid", scale)) app.showGrid = !app.showGrid; y += btn + gap; Rectangle rMap = { bx, y, btn, btn }; if (RailButton(rMap, IconMinimap, app.showMinimap, loaded, "Minimap (click/drag to navigate)", scale)) app.showMinimap = !app.showMinimap; y += btn + gap * 2.4f; // --- Settings popouts --- Rectangle rFFT = { bx, y, btn, btn }; float fftY = y; if (RailButton(rFFT, IconFFT, g_railPopout == RAIL_POP_FFT, loaded, TextFormat("FFT size: %d", app.fftSize), scale)) { g_railPopout = (g_railPopout == RAIL_POP_FFT) ? RAIL_POP_NONE : RAIL_POP_FFT; g_railPopoutY = fftY; } y += btn + gap; Rectangle rCmap = { bx, y, btn, btn }; float cmapY = y; if (RailButton(rCmap, IconColormap, g_railPopout == RAIL_POP_LEVELS, true, "Colours & levels", scale)) { g_railPopout = (g_railPopout == RAIL_POP_LEVELS) ? RAIL_POP_NONE : RAIL_POP_LEVELS; g_railPopoutY = cmapY; } y += btn + gap; // --- Annotations (only when the file carries any) --- bool hasAnno = app.annotations.loaded && app.annotations.eventCount > 0; if (hasAnno) { y += gap * 1.4f; Rectangle rAnno = { bx, y, btn, btn }; float annoY = y; if (RailButton(rAnno, IconAnnotations, app.showAnnotations, true, "Annotations (right-click for kinds)", scale)) { app.showAnnotations = !app.showAnnotations; InvalidateMinimap(); } // Right-click opens the kind picker without toggling the overlay. if (CheckCollisionPointRec(GetMousePosition(), rAnno) && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) { g_railPopout = (g_railPopout == RAIL_POP_ANNOTATIONS) ? RAIL_POP_NONE : RAIL_POP_ANNOTATIONS; g_railPopoutY = annoY; } y += btn + gap; Rectangle rLane = { bx, y, btn, btn }; if (RailButton(rLane, IconTimeline, app.showTimeline, true, app.timelineExpanded ? "Timeline lane (right-click: collapse)" : "Timeline lane (right-click: expand)", scale)) app.showTimeline = !app.showTimeline; // Right-click switches the lane between a single sparkline and one row // per kind, without having to turn it off and on. if (CheckCollisionPointRec(GetMousePosition(), rLane) && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) { if (!app.showTimeline) app.showTimeline = true; else app.timelineExpanded = !app.timelineExpanded; } y += btn + gap; if (app.collisionCount > 0) { Rectangle rColl = { bx, y, btn, btn }; if (RailButton(rColl, IconCollision, app.showCollisions, true, TextFormat("Collisions: %d in %d regions (N)", app.collisionCount, app.collisionRegionCount), scale)) { app.showCollisions = !app.showCollisions; InvalidateMinimap(); } y += btn + gap; } } else if (g_railPopout == RAIL_POP_ANNOTATIONS) { g_railPopout = RAIL_POP_NONE; } // A click anywhere outside the rail and its popout dismisses the popout. if (g_railPopout != RAIL_POP_NONE && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && GetMousePosition().x > railW && !SidebarCapturesMouse()) { g_railPopout = RAIL_POP_NONE; } if (needsRegen && app.stftComputed) { // dB floor / colormap only — re-map the cached reassignment, don't recompute it. ColorizeSpectrogram(&app.spectrogramImage, &app.spectrogramTexture); app.visibleTextureValid = false; } } static void DrawSlider(Rectangle bounds, float value) { // Background DrawRectangleRec(bounds, DARKGRAY); DrawRectangleLinesEx(bounds, 1, GRAY); // Fill float fillWidth = (bounds.width - 4) * value; DrawRectangle(bounds.x + 2, bounds.y + 2, fillWidth, bounds.height - 4, BLUE); // Handle float handleX = bounds.x + 2 + fillWidth; DrawRectangle(handleX - 3, bounds.y + 1, 6, bounds.height - 2, WHITE); } static bool UpdateSlider(Rectangle bounds, float* value) { bool changed = false; if (CheckCollisionPointRec(GetMousePosition(), bounds) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { float newValue = (GetMousePosition().x - bounds.x - 2) / (bounds.width - 4); newValue = Clamp(newValue, 0.0f, 1.0f); if (fabsf(newValue - *value) > 0.001f) { *value = newValue; changed = true; } } return changed; } // ===== About / Help dialog ===== void DrawAboutDialog(void) { if (!app.showAbout) return; float scale = GetUIScale(); int sw = GetScreenWidth(); int sh = GetScreenHeight(); // Dim the background; a click outside the panel (or Esc/F1) closes it. DrawRectangle(0, 0, sw, sh, Fade(BLACK, 0.7f)); float pw = 560 * scale; float ph = 540 * scale; Rectangle panel = { (sw - pw) / 2, (sh - ph) / 2, pw, ph }; DrawRectangleRec(panel, (Color){ 30, 30, 38, 255 }); DrawRectangleLinesEx(panel, 2, (Color){ 90, 90, 110, 255 }); float px = panel.x + 24 * scale; float py = panel.y + 20 * scale; DrawTextScaled("rspektrum - synchrosqueezed spectrogram viewer", px, py, 18, WHITE); py += 34 * scale; // Body lines. NULL = blank spacer; headings start with no leading spaces. const char* lines[] = { "Amplitude scale", " Relative: ceiling tracks the signal peak; the slider sets how many", " dB of range are shown below it (good for seeing structure).", " Absolute: fixed dBFS scale, 0 dBFS = digital full scale (sample = 1.0).", " Brightness then reflects a real level, comparable across files.", "", "Why dBFS and not dBm", " A WAV carries no power reference (impedance / receiver calibration),", " so absolute levels are shown in dBFS, not dBm. 0 dBFS = full scale.", "", "Accuracy caveats", " - The Hann window adds a fixed ~6 dB coherent-gain offset to", " spectral magnitudes.", " - Pixels show synchrosqueezed (reassigned) energy, not raw FFT bins.", " Good to ~a dB for visualization; not lab-grade metrology.", }; int n = sizeof(lines) / sizeof(lines[0]); for (int i = 0; i < n; i++) { const char* s = lines[i]; bool heading = (s[0] != '\0' && s[0] != ' '); DrawTextScaled(s, px, py, heading ? 14 : 13, heading ? (Color){ 150, 200, 255, 255 } : LIGHTGRAY); py += (s[0] == '\0' ? 8 : 18) * scale; } // Keys — rendered straight from the keymap table so this list can never // drift from the actual bindings. py += 10 * scale; DrawTextScaled("Keys", px, py, 14, (Color){ 150, 200, 255, 255 }); py += 20 * scale; int kn; const KeyBinding* km = GetKeymap(&kn); for (int i = 0; i < kn; i++) { DrawTextScaled(TextFormat(" %-5s %s", km[i].label, km[i].help), px, py, 13, LIGHTGRAY); py += 16 * scale; } DrawTextScaled(" Mouse wheel = zoom (Shift = time only, Ctrl = freq only)", px, py, 13, LIGHTGRAY); py += 16 * scale; DrawTextScaled(" Alt+drag = pan, drag = select box", px, py, 13, LIGHTGRAY); DrawTextScaled("F1 / Esc / click to close", panel.x + pw - 196 * scale, panel.y + ph - 26 * scale, 12, GRAY); // Note: opening/closing is handled in the main input loop (not here) so the // same click/keypress that opens the dialog can't immediately close it. } // ===== Auto-crop notice splash ===== // // Raised by ApplyAutoCrop when the view actually shrank. Modal: routes // through UiModalOpen() so the spectrogram doesn't accept clicks underneath. // Two buttons: // [Uncrop] — restore full freq axis and full-duration view // [OK] — dismiss; keep the cropped view // Esc closes (= OK). Clicks outside the panel do nothing (avoids losing the // crop by missing a button by a few px). // Auto-crop notice, as a bottom-right toast rather than a modal. // // Auto-crop is a helpful default, not a decision worth blocking on: a modal // stole focus and demanded a click before the user could look at the file they // had just opened. The toast states what happened, offers Undo, and expires on // its own — but only counts down while the window is focused, so a crop applied // during a background load is still there to read when the user comes back. void DrawAutocropNotice(void) { if (!app.autocropNoticeActive) return; float scale = GetUIScale(); int sw = GetScreenWidth(); int sh = GetScreenHeight(); const char* title = "Auto-crop applied"; float titleW = MeasureTextScaled(title, 13); float msgW = MeasureTextScaled(app.autocropNoticeMsg, 11); float btnW = 76 * scale, btnH = 24 * scale; float pw = fmaxf(fmaxf(titleW, msgW) + 28 * scale, btnW * 2 + 44 * scale); float ph = 92 * scale; float margin = 16 * scale; Rectangle panel = { sw - pw - margin, sh - ph - margin, pw, ph }; DrawRectangleRec(panel, (Color){ 28, 28, 36, 240 }); DrawRectangleLinesEx(panel, 1, (Color){ 150, 120, 190, 255 }); DrawTextScaled(title, panel.x + 12 * scale, panel.y + 10 * scale, 13, (Color){ 200, 170, 240, 255 }); DrawTextScaled(app.autocropNoticeMsg, panel.x + 12 * scale, panel.y + 30 * scale, 11, LIGHTGRAY); float btnY = panel.y + ph - btnH - 10 * scale; Rectangle undoBtn = { panel.x + 12 * scale, btnY, btnW, btnH }; Rectangle okBtn = { panel.x + pw - btnW - 12 * scale, btnY, btnW, btnH }; bool undoHover = CheckCollisionPointRec(GetMousePosition(), undoBtn); bool okHover = CheckCollisionPointRec(GetMousePosition(), okBtn); DrawPanelBox(undoBtn, undoHover ? (Color){ 100, 60, 60, 255 } : (Color){ 70, 40, 40, 255 }, (Color){ 230, 160, 160, 255 }); DrawTextScaled("Undo", undoBtn.x + btnW * 0.5f - MeasureTextScaled("Undo", 12) * 0.5f, undoBtn.y + 5 * scale, 12, WHITE); DrawPanelBox(okBtn, okHover ? (Color){ 60, 90, 60, 255 } : (Color){ 45, 60, 45, 255 }, (Color){ 160, 200, 160, 255 }); DrawTextScaled("Dismiss", okBtn.x + btnW * 0.5f - MeasureTextScaled("Dismiss", 12) * 0.5f, okBtn.y + 5 * scale, 12, WHITE); // Remaining-time strip along the bottom edge, so the auto-expiry is visible // rather than the toast just vanishing mid-read. float frac = app.autocropNoticeTimer / AUTOCROP_NOTICE_SECONDS; if (frac < 0.0f) frac = 0.0f; if (frac > 1.0f) frac = 1.0f; DrawRectangle((int)panel.x, (int)(panel.y + ph - 2 * scale), (int)(pw * frac), (int)(2 * scale), (Color){ 150, 120, 190, 200 }); if (undoHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.displayMaxFreqHz = 0.0f; app.view.start = 0.0f; app.view.end = 1.0f; app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f; app.visibleTextureValid = false; app.autocropNoticeActive = false; } else if (okHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.autocropNoticeActive = false; } // Count down only while focused: a toast that expired behind another window // would be gone before it was ever seen. // // The step is clamped because auto-crop fires on the frame right after the // blocking STFT compute, and GetFrameTime() on that frame reports the whole // compute — seconds on a long capture. Unclamped, that single frame drained // the entire 5 s budget and the toast flashed by in an instant. if (IsWindowFocused()) { float dt = GetFrameTime(); if (dt > 0.1f) dt = 0.1f; // ignore load hitches / debugger stalls app.autocropNoticeTimer -= dt; if (app.autocropNoticeTimer <= 0.0f) app.autocropNoticeActive = false; } } // Popout panels, drawn in their own late pass so they float above the // spectrogram, scope and everything else. DrawSidebar runs early (the layout // depends on the rail's width), and anything it drew was painted over. void DrawSidebarPopouts(void) { if (app.sidebarCollapsed) return; // Hovered icon's tooltip, deferred from the early pass (see RailButton). if (g_railTipText) { float sc = GetUIScale(); float tw = MeasureTextScaled(g_railTipText, 11) + 12 * sc; float th = 18 * sc; Rectangle tr = { g_railTipX + 6 * sc, g_railTipY - th * 0.5f, tw, th }; if (tr.x + tw > GetScreenWidth()) tr.x = GetScreenWidth() - tw - 4 * sc; DrawRectangleRec(tr, (Color){ 20, 20, 26, 245 }); DrawRectangleLinesEx(tr, 1, (Color){ 90, 90, 105, 255 }); DrawTextScaled(g_railTipText, tr.x + 6 * sc, tr.y + 3 * sc, 11, LIGHTGRAY); } if (g_railPopout == RAIL_POP_NONE) return; float scale = GetUIScale(); float railW = app.sidebarWidth * scale; bool hasAnno = app.annotations.loaded && app.annotations.eventCount > 0; bool loaded = app.loaded; bool needsRegen = false; // ---- Popout panels ---- if (g_railPopout == RAIL_POP_FFT) { Rectangle p = RailPopoutPanel(railW, g_railPopoutY, 168 * scale, 96 * scale, scale); DrawTextScaled("FFT size", p.x + 10 * scale, p.y + 8 * scale, 12, WHITE); DrawTextScaled(TextFormat("%d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), p.x + 10 * scale, p.y + 28 * scale, 11, LIGHTGRAY); Rectangle minus = { p.x + 10 * scale, p.y + 52 * scale, 34 * scale, 26 * scale }; Rectangle plus = { p.x + p.width - 44 * scale, p.y + 52 * scale, 34 * scale, 26 * scale }; DrawPanelBox(minus, (Color){ 55, 55, 66, 255 }, GRAY); DrawPanelBox(plus, (Color){ 55, 55, 66, 255 }, GRAY); DrawTextScaled("-", minus.x + 14 * scale, minus.y + 5 * scale, 16, WHITE); DrawTextScaled("+", plus.x + 12 * scale, plus.y + 5 * scale, 16, WHITE); if (Clicked(minus) && app.fftSize > FFT_SIZE_MIN) ChangeFFTSize(app.fftSize / 2); if (Clicked(plus) && app.fftSize < FFT_SIZE_MAX) ChangeFFTSize(app.fftSize * 2); } else if (g_railPopout == RAIL_POP_LEVELS) { Rectangle p = RailPopoutPanel(railW, g_railPopoutY, 200 * scale, 208 * scale, scale); float py = p.y + 8 * scale; float pw = p.width - 20 * scale; DrawTextScaled("Colormap", p.x + 10 * scale, py, 12, WHITE); py += 18 * scale; Rectangle cmapBtn = { p.x + 10 * scale, py, pw, 24 * scale }; if (Clicked(cmapBtn)) { app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); needsRegen = true; InvalidateMinimap(); // thumbnail is colourized with this map } DrawPanelBox(cmapBtn, (Color){ 55, 55, 66, 255 }, GRAY); DrawTextScaled(ColormapName(app.colormap), cmapBtn.x + 8 * scale, cmapBtn.y + 5 * scale, 12, WHITE); py += 30 * scale; DrawTextScaled(app.amplitudeMode == SCALE_ABSOLUTE ? "Scale: Absolute" : "Scale: Relative", p.x + 10 * scale, py, 12, LIGHTGRAY); py += 16 * scale; Rectangle modeBtn = { p.x + 10 * scale, py, pw, 22 * scale }; if (Clicked(modeBtn)) { app.amplitudeMode = (app.amplitudeMode == SCALE_ABSOLUTE) ? SCALE_RELATIVE : SCALE_ABSOLUTE; AutoScaleAmplitude(&app.stft); needsRegen = true; } DrawPanelBox(modeBtn, (Color){ 55, 55, 66, 255 }, GRAY); DrawTextScaled("Toggle scale mode", modeBtn.x + 8 * scale, modeBtn.y + 4 * scale, 11, LIGHTGRAY); py += 30 * scale; if (app.amplitudeMode == SCALE_ABSOLUTE) { DrawTextScaled(TextFormat("Floor: %.0f dBFS", app.absoluteFloorDb), p.x + 10 * scale, py, 11, LIGHTGRAY); py += 16 * scale; Rectangle s = { p.x + 10 * scale, py, pw, 14 * scale }; float t = (app.absoluteFloorDb + 120.0f) / 120.0f; DrawSlider(s, t); if (UpdateSlider(s, &t)) { app.absoluteFloorDb = t * 120.0f - 120.0f; AutoScaleAmplitude(&app.stft); needsRegen = true; } } else { DrawTextScaled(TextFormat("Dyn range: %.0f dB", app.dynRangeDb), p.x + 10 * scale, py, 11, LIGHTGRAY); py += 16 * scale; Rectangle s = { p.x + 10 * scale, py, pw, 14 * scale }; float t = (app.dynRangeDb - 20.0f) / 100.0f; DrawSlider(s, t); if (UpdateSlider(s, &t)) { app.dynRangeDb = 20.0f + t * 100.0f; AutoScaleAmplitude(&app.stft); needsRegen = true; } } py += 26 * scale; if (loaded) { DrawTextScaled(TextFormat("Display max: %.0f Hz", EffectiveMaxFreqHz()), p.x + 10 * scale, py, 11, LIGHTGRAY); py += 16 * scale; Rectangle s = { p.x + 10 * scale, py, pw - 40 * scale, 14 * scale }; float nyq = app.signal.sampleRate * 0.5f; float t = (nyq > 0.0f) ? EffectiveMaxFreqHz() / nyq : 1.0f; DrawSlider(s, t); if (UpdateSlider(s, &t)) { app.displayMaxFreqHz = fmaxf(t * nyq, 100.0f); app.visibleTextureValid = false; } Rectangle autoBtn = { p.x + p.width - 36 * scale, py - 2 * scale, 26 * scale, 18 * scale }; if (Clicked(autoBtn)) ApplyAutoCrop(); DrawPanelBox(autoBtn, (Color){ 60, 60, 72, 255 }, GRAY); DrawTextScaled("auto", autoBtn.x + 3 * scale, autoBtn.y + 3 * scale, 9, WHITE); } } else if (g_railPopout == RAIL_POP_ANNOTATIONS && hasAnno) { int kinds = 0; for (int k = 0; k < MLNL_KIND_MAX; k++) if (app.annotations.kindPresent[k]) kinds++; float h = (70 + kinds * 18) * scale; Rectangle p = RailPopoutPanel(railW, g_railPopoutY, 190 * scale, h, scale); float py = p.y + 8 * scale; DrawTextScaled("Show kinds", p.x + 10 * scale, py, 12, WHITE); py += 20 * scale; for (int k = 0; k < MLNL_KIND_MAX; k++) { if (!app.annotations.kindPresent[k]) continue; Rectangle cb = { p.x + 10 * scale, py, 14 * scale, 14 * scale }; if (Clicked(cb)) { app.annotationKindEnabled[k] = !app.annotationKindEnabled[k]; InvalidateMinimap(); // overlay ticks are baked into the thumbnail } DrawPanelBox(cb, app.annotationKindEnabled[k] ? (Color){ 140, 100, 200, 255 } : DARKGRAY, LIGHTGRAY); DrawTextScaled(MlnlKindName((MlnlKind)k), cb.x + 20 * scale, cb.y - 1 * scale, 11, LIGHTGRAY); py += 18 * scale; } py += 6 * scale; DrawTextScaled(TextFormat("Opacity: %d%%", (int)(app.annotationOpacityBase * 100)), p.x + 10 * scale, py, 11, LIGHTGRAY); py += 15 * scale; Rectangle os = { p.x + 10 * scale, py, p.width - 20 * scale, 12 * scale }; DrawSlider(os, app.annotationOpacityBase); if (UpdateSlider(os, &app.annotationOpacityBase)) app.annotationOpacityHover = fmaxf(app.annotationOpacityBase, app.annotationOpacityHover); } if (needsRegen && app.stftComputed) { ColorizeSpectrogram(&app.spectrogramImage, &app.spectrogramTexture); app.visibleTextureValid = false; } }