Files
rspektrum/src/ui.c
T
tyler cce96659b1 feat: menubar + icon rail, minimap, Blender-style navigation
A pass over the whole interface, driven by using it on a 5.7-hour capture.

**Layout.** The 320px sidebar of labelled widgets is now a 46px icon rail —
one column of square buttons, sized so it costs the spectrogram as little
width as possible — plus a menubar for one-shot actions. Menu items are
defined by naming a keymap entry, so an item reuses that binding's action,
gate and shortcut label and the two can't drift; items grey out under
exactly the conditions that make the shortcut a no-op. Settings that need
more than an on/off (FFT size, colours/levels, annotation kinds) open as
popouts beside the rail rather than widening it. Every toggle has exactly
one home: nothing is reachable from both the rail and a menu.

Icons are drawn from raylib primitives rather than an atlas or font
glyphs — the bundled font has no symbol coverage, and vector shapes stay
crisp at any UI scale with no assets to ship.

**Navigation.** Left-drag now pans and Ctrl+drag box-selects, with Tab
swapping which is bare (Ctrl always means "the other one", so either mode
does both). Previously a bare left-drag did three different things
depending on invisible state, with no cursor feedback; the cursor now
reports the active gesture. Wheeling a scrollbar pans that axis, or zooms
it with Shift.

**Minimap.** Whole-file thumbnail in the top-right with the current view
drawn on it; click or drag to scrub, corner handle switches between two
sizes. Rendered once per size into a cached texture and rebuilt only when
its content changes — panning and zooming just move the rectangle drawn on
top. The reduction is strided (each thumbnail cell samples at most 8x8),
because reducing every segment x bin meant ~1 G reads per rebuild and a
visible hitch on every overlay toggle.

**Fixes found along the way:**
- The timeline lane mapped events across the whole file while the
  spectrogram above it showed a zoomed window, so the two only lined up at
  full zoom-out and an event's tick sat nowhere near its burst. It is also
  properly toggleable now: the old flag only grew an always-present lane.
- Clicks preferred the spectrogram over the minimap. Input handling runs
  ~900 lines before the draw pass that computed the minimap's rect, so a
  press there started a pan AND a scrub — two handlers writing app.view in
  one frame. Geometry queries that input depends on now live outside the
  draw pass.
- Repainting during the background fill re-ran the full synchrosqueeze
  every 0.5 s. Zoomed out that is ~0.5 G bin-visits with four trig calls
  each, twice a second, for minutes — while showing almost nothing new,
  since folded segments land in columns already drawn. The interval now
  scales with how much work a repaint actually costs.
- IsUserInteracting() sweeps 512 key codes and was called three times a
  frame; memoized per frame.
- Frequency labels were drawn at a fixed offset wider than their gutter and
  overflowed into the rail. They are measured and right-aligned now, with
  one format chosen per axis so the column doesn't mix "3k" with "2.3k".
- The horizontal scrollbar is pinned to the window bottom; it used to sit
  mid-layout competing with the scope's divider, and the scope covered it
  outright at larger window sizes.
- The scope starts hidden — the spectrogram is the primary view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 15:28:44 -07:00

1361 lines
56 KiB
C

// 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;
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;
}
}