// 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 <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <math.h>

// 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(&region, 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;
            // Leave the marker where playback stopped rather than snapping it
            // to the start: it stays grabbable, and the next play resumes
            // from here. playheadT is a fraction of the PLAYED span, which is
            // not the selection once a scrub offset is in play — convert
            // through absolute file time, the only frame the two share.
            {
                float stopAbs = app.playSelStart +
                                app.playheadT * (app.playSelEnd - app.playSelStart);
                float selSpan = app.sel.timeEnd - app.sel.timeStart;
                app.playFromT = (selSpan > 1e-6f)
                              ? Clamp((stopAbs - app.sel.timeStart) / selSpan, 0.0f, 0.999f)
                              : 0.0f;
            }
            app.playheadSeekT = 0.0f;
        } else {
            PlaySelectedRegion();
            app.isPlaying = true;
            // Must clear: PlaySelectedRegion restarts from the top, and leaving
            // this set left the playhead parked at the end of the previous run
            // while the audio played from the beginning.
            app.playbackFinished = false;
        }
    }
    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;
    }
}
