From cce96659b18027407f53a331832f37c287f81515 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 12 Aug 2026 15:28:44 -0700 Subject: [PATCH] feat: menubar + icon rail, minimap, Blender-style navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN --- README.md | 52 +- src/render.c | 302 ++++++++++- src/render.h | 15 + src/spectrogram.c | 295 +++++++++- src/spectrogram_types.h | 89 ++- src/ui.c | 1137 +++++++++++++++++++++++++++------------ src/ui.h | 10 + 7 files changed, 1499 insertions(+), 401 deletions(-) diff --git a/README.md b/README.md index efedf31..ccf60d2 100644 --- a/README.md +++ b/README.md @@ -126,17 +126,24 @@ or pressing **O** for the file browser. Try the bundled sample: ### Controls +**Navigating.** A left-drag pans by default and **Ctrl+drag** draws a selection +box; **Tab** (or the rail's pan/select icons) swaps which one is bare, and Ctrl +always means "the other one", so either mode does both without switching back. +Middle-drag always pans. + | Input | Action | |-------|--------| -| **O** | Open file browser | +| **LMB drag** | Pan the view (**Ctrl+drag** to box-select) | +| **Tab** | Swap pan / select mode | +| **Middle-drag** / **Alt+drag** | Pan, regardless of mode | | **Mouse wheel** | Zoom both axes (preserves aspect ratio) | | **Shift+wheel** | Zoom the time axis only | | **Ctrl+wheel** | Zoom the frequency axis only | -| **Alt+drag** / **middle-drag** | Pan the view | -| **LMB drag** | Select a time + frequency region | +| **Wheel on a scrollbar** | Pan that axis (**Shift** to zoom it) | | **Space** | Play / stop the selected region | | **Hover an annotation** | Tooltip with that frame's mLnL detail; lists **every** overlapping frame under the cursor | | **N** / **Shift+N** | Jump to the next / previous collision | +| **O** | Open file browser | | **P** | Show / hide the waveform scope | | **M** | Marker / ruler tool | | **S** | Spectrum slice (PSD) | @@ -148,8 +155,30 @@ or pressing **O** for the file browser. Try the bundled sample: | **F1** | About / help | | **Esc** | Clear selection / close dialog | -Most controls are also available as buttons in the left sidebar (colormap, floor, -dynamic range, annotation opacity, grid, …). +### Layout + +A menubar across the top holds one-shot actions (**File** — open, export +PNG/WAV; **View** — reset/zoom, hide the icon rail, fullscreen; **Annotations** — +jump to next collision; **Help**). Everything that toggles lives on the rail +instead, so no control has two homes. Menu items are defined by naming a keyboard shortcut, so an item and +its key can never drift apart, and items grey out under exactly the conditions +that make the shortcut a no-op. + +Down the left is a narrow **icon rail** — one column of square buttons, sized so +it costs the spectrogram as little width as possible. Hover any icon for a +tooltip. Left to right in function: play/stop and clear selection; pan/select +mode; marker, spectrum slice, scope, grid, minimap; FFT size and colour/level +popouts; annotations, collisions, and the timeline lane. The three settings +popouts open beside the rail rather than widening it. `View → Hide icon rail` +hands its width back to the spectrogram. + +The **minimap** (top-right, toggled from the rail) is a thumbnail of the whole +capture with the current view drawn on it — click or drag anywhere on it to +scrub. Annotation density runs along its bottom edge and collisions along its +top. The corner handle switches between two sizes. It is rendered once into a +texture and only rebuilt when its *content* changes (new file, colormap, +overlays toggled); panning and zooming just move the rectangle drawn on top, so +navigation costs nothing. ### Inspecting overlapping transmissions @@ -254,6 +283,14 @@ paths. frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB. - **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist), colour = amplitude. +- **Loading** — the STFT overview is computed in one blocking pass behind the + progress panel. It used to advance a fixed number of segments per frame, which + made loading frame-paced rather than compute-bound: the frame limiter, not the + FFT, set the speed, so a 478k-segment capture spent over a minute waiting + between frames. The tell was that backgrounding the window — which skips + presenting entirely — loaded the same file in seconds. Background work also + continues while the window is unfocused, so a long capture can be left to + finish behind another window. - **Long files** — two things keep cost tied to what's on screen rather than to total duration. The spectrogram image is built for the *visible* segment range (capped at 8192 px wide), so a multi-hour capture renders at all — an @@ -285,8 +322,9 @@ paths. src/ spectrogram.c # entry point, main loop, CLI args, headless render stft.c / fft.c # STFT + FFT - render.c # spectrogram, annotations, tooltips, scope - ui.c # sidebar, file browser, buttons + render.c # spectrogram, annotations, tooltips, minimap, scope + ui.c # menubar, icon rail + popouts, file browser + primitives.c # waveform scope + its min/max envelope summary audio.c # WAV load (ffmpeg fallback), bandpass, playback, WAV export mlnl.c / mlnl.h # mLnL annotation chunk parser platform_*.c # per-OS shims (linux / win32 / web) diff --git a/src/render.c b/src/render.c index 569c391..d34b23d 100644 --- a/src/render.c +++ b/src/render.c @@ -160,7 +160,7 @@ static const ColormapDef COLORMAPS[COLORMAP_COUNT] = { [COLORMAP_COOL] = { "Cool", CmapCool }, }; -static Color GetColormapColor(float t, ColormapType type) +Color GetColormapColor(float t, ColormapType type) { if (type < 0 || type >= COLORMAP_COUNT) return GRAY; return COLORMAPS[type].fn(Clamp(t, 0.0f, 1.0f)); @@ -474,14 +474,30 @@ void DrawLabels(Rectangle bounds) DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor); } - // Draw labels at the coarser spacing + // Draw labels at the coarser spacing. + // + // Right-aligned against the axis and compacted ("2.4k", not "2400Hz"): the + // old form was drawn at a fixed bounds.x-70 offset while the gutter is only + // freqLabelWidth wide, so long labels overflowed left into the icon rail. + // Measuring each label and aligning its right edge to the axis keeps every + // label inside the gutter no matter how wide the value is. The unit is + // stated once in the axis banner rather than repeated on every tick. + // Pick ONE format for the whole axis rather than per-label, so the column + // doesn't mix "3k" with "2.3k" and read as ragged. The decision is made + // from the label spacing: if every label lands on a whole kHz they all get + // "%dk", otherwise they all carry the same single decimal. + bool useKilo = (freqMax >= 1000.0f); + bool wholeKilo = useKilo && (labelSpacing % 1000 == 0); + for (int hz = firstTick; hz <= freqMax; hz += labelSpacing) { float t = (hz - freqMin) / freqRange; float y = bounds.y + bounds.height - t * bounds.height; char label[32]; - if (hz < 10000) sprintf(label, "%.0fHz", (float)hz); - else sprintf(label, "%.0fkHz", (float)hz / 1000.0f); - DrawTextScaled(label, bounds.x - 70, y - 5, baseFontSize, textColor); + if (!useKilo) sprintf(label, "%d", hz); + else if (wholeKilo) sprintf(label, "%dk", hz / 1000); + else sprintf(label, "%.1fk", hz / 1000.0f); + float lw = MeasureTextScaled(label, baseFontSize); + DrawTextScaled(label, bounds.x - 8 - lw, y - 5, baseFontSize, textColor); } } @@ -1517,21 +1533,6 @@ void DrawAnnotations(Rectangle bounds) app.hoverStackCount = stackCount; for (int i = 0; i < stackCount; i++) app.hoverStack[i] = stack[i]; - // ---- Tooltip ---- Timeline hover takes priority over spectrogram hover - // (the lane is the active surface when you're hovering it). - int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent; - - if (app.hoverStackCount > 1 && app.hoveredTimelineEvent < 0) { - // Several boxes under the cursor: one line each beats full detail for - // one, since the question being asked is "who else is in here?". - DrawHoverStackTooltip(bounds, m, stackTotal); - } else if (tipFor >= 0) { - const MlnlEvent* e = &app.annotations.events[tipFor]; - char lines[12][96]; - int n = BuildEventLines(e, lines, 12); - DrawTooltip(bounds, m, lines, n, EventColor(e)); - } - // ---- Collision overlay ---- // Marks where transmissions genuinely overlap in time AND frequency. Drawn // as a band per merged region rather than per event: at a wide zoom a @@ -1575,6 +1576,23 @@ void DrawAnnotations(Rectangle bounds) } } + // ---- Tooltip ---- Drawn last so it floats above the collision overlay: + // the red bands are a background cue, the tooltip is what's being read. + // Timeline hover takes priority over spectrogram hover (the lane is the + // active surface when you're hovering it). + int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent; + + if (app.hoverStackCount > 1 && app.hoveredTimelineEvent < 0) { + // Several boxes under the cursor: one line each beats full detail for + // one, since the question being asked is "who else is in here?". + DrawHoverStackTooltip(bounds, m, stackTotal); + } else if (tipFor >= 0) { + const MlnlEvent* e = &app.annotations.events[tipFor]; + char lines[12][96]; + int n = BuildEventLines(e, lines, 12); + DrawTooltip(bounds, m, lines, n, EventColor(e)); + } + if (app.annotations.truncated) { const char* msg = "mLnL: truncated"; float fs = 10.0f; @@ -1657,6 +1675,234 @@ static void ImageBoxLabel(Image* img, Font font, Rectangle box, const char* text } // Draw the mLnL overlay onto a full-resolution spectrogram Image (no GL). + +// ===== Minimap ===== +// A whole-file thumbnail with the current view drawn on it, for navigating a +// long capture without zooming out and back in. +// +// The thumbnail is built ONCE into a texture and reused every frame. Rebuilding +// it per frame would mean re-reducing the entire STFT (hundreds of thousands of +// segments on a long file) for a 256x64 image — the single most wasteful thing +// this UI could do. It is invalidated only when its content actually changes: +// a new file, a colormap change, or the annotation/collision overlays being +// toggled. Panning and zooming just move the viewport rectangle drawn on top, +// which costs nothing. + +void InvalidateMinimap(void) { app.minimapValid[0] = app.minimapValid[1] = false; } + +// Peak amplitude over one thumbnail cell. Strided rather than exhaustive: the +// output is a 256x64 blob, so sampling every Nth segment and bin gives the same +// picture for a fraction of the reads. Without this, reducing a multi-hour +// capture means ~1 G amplitude reads per rebuild, which is a visible hitch every +// time an overlay is toggled. The stride is chosen so each cell takes a bounded +// number of samples no matter how long the file is. +#define MINIMAP_CELL_SAMPLES 8 +static float MinimapCellPeak(int s0, int s1, int b0, int b1) +{ + int sStride = (s1 - s0) / MINIMAP_CELL_SAMPLES; + if (sStride < 1) sStride = 1; + int bStride = (b1 - b0) / MINIMAP_CELL_SAMPLES; + if (bStride < 1) bStride = 1; + + float m = 0.0f; + for (int s = s0; s < s1; s += sStride) { + const StftSegment* seg = &app.stft.segments[s]; + if (!seg->spectrum) continue; // not yet filled in + for (int b = b0; b < b1; b += bStride) { + float a = seg->spectrum[b].amplitude; + if (a > m) m = a; + } + } + return m; +} + + +static void BuildMinimapTexture(int slot) +{ + int TW = slot ? MINIMAP_TEX_W_LG : TW; + int TH = slot ? MINIMAP_TEX_H_LG : TH; + + if (app.minimapTexture[slot].id != 0) UnloadTexture(app.minimapTexture[slot]); + app.minimapTexture[slot] = (Texture2D){ 0 }; + app.minimapValid[slot] = true; + if (!app.stftComputed || app.stft.numSegments <= 0) return; + + Image img = GenImageColor(TW, TH, (Color){ 12, 12, 16, 255 }); + Color* px = (Color*)img.data; + + int nSeg = app.stft.numSegments; + int nBin = app.stft.segments[0].numBins; + + // Peak-reduce the STFT straight into the thumbnail: for each output pixel, + // take the max amplitude over the segments and bins it covers. Max (not + // mean) so a short burst still shows as a mark instead of averaging away. + float peak = 0.0001f; + for (int x = 0; x < TW; x++) { + int s0 = (int)((long long)x * nSeg / TW); + int s1 = (int)((long long)(x + 1) * nSeg / TW); + if (s1 <= s0) s1 = s0 + 1; + if (s1 > nSeg) s1 = nSeg; + + for (int y = 0; y < TH; y++) { + // Row 0 is the top of the image = highest frequency. + int b0 = (int)((long long)(TH - 1 - y) * nBin / TH); + int b1 = (int)((long long)(TH - y) * nBin / TH); + if (b1 <= b0) b1 = b0 + 1; + if (b1 > nBin) b1 = nBin; + + float m = MinimapCellPeak(s0, s1, b0, b1); + if (m > peak) peak = m; + } + } + + // Second pass: colourize against the peak found above. Two passes keeps the + // scale honest without needing a separate float buffer for a 16k-pixel image. + for (int x = 0; x < TW; x++) { + int s0 = (int)((long long)x * nSeg / TW); + int s1 = (int)((long long)(x + 1) * nSeg / TW); + if (s1 <= s0) s1 = s0 + 1; + if (s1 > nSeg) s1 = nSeg; + for (int y = 0; y < TH; y++) { + int b0 = (int)((long long)(TH - 1 - y) * nBin / TH); + int b1 = (int)((long long)(TH - y) * nBin / TH); + if (b1 <= b0) b1 = b0 + 1; + if (b1 > nBin) b1 = nBin; + float m = MinimapCellPeak(s0, s1, b0, b1); + float db = AmplitudeToDecibels(m / peak); + float t = Clamp((db + 60.0f) / 60.0f, 0.0f, 1.0f); + px[y * TW + x] = (m > 0.0f) ? GetColormapColor(t, app.colormap) + : (Color){ 12, 12, 16, 255 }; + } + } + + // Bake the overlays in, so toggling them is what forces a rebuild rather + // than costing anything per frame. + double dur = app.signal.duration; + if (dur > 0.0 && app.annotations.loaded) { + if (app.showAnnotations) { + for (int i = 0; i < app.annotations.eventCount; i++) { + const MlnlEvent* e = &app.annotations.events[i]; + if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue; + int x0 = (int)(e->t_start / dur * TW); + int x1 = (int)(e->t_end / dur * TW); + if (x1 <= x0) x1 = x0 + 1; + if (x0 < 0) x0 = 0; + if (x1 > TW) x1 = TW; + // A thin tick along the bottom edge: density, not detail. + for (int x = x0; x < x1; x++) + for (int y = TH - 3; y < TH; y++) + px[y * TW + x] = (Color){ 120, 200, 255, 255 }; + } + } + if (app.showCollisions) { + for (int i = 0; i < app.collisionRegionCount; i++) { + const CollisionRegion* cr = &app.collisionRegions[i]; + int x0 = (int)(cr->t0 / dur * TW); + int x1 = (int)(cr->t1 / dur * TW); + if (x1 <= x0) x1 = x0 + 1; + if (x0 < 0) x0 = 0; + if (x1 > TW) x1 = TW; + for (int x = x0; x < x1; x++) + for (int y = 0; y < 3; y++) + px[y * TW + x] = (Color){ 255, 70, 70, 255 }; + } + } + } + + app.minimapTexture[slot] = LoadTextureFromImage(img); + SetTextureFilter(app.minimapTexture[slot], TEXTURE_FILTER_BILINEAR); + UnloadImage(img); +} + +// Where the minimap sits, without drawing it. Input handling runs hundreds of +// lines before the draw pass, and it needs this rect to know whether a click +// belongs to the minimap or to the spectrogram underneath — otherwise the pan +// handler claims the press first and both end up writing app.view in the same +// frame, which reads as the drag jumping around. +Rectangle MinimapBounds(void) +{ + if (!app.showMinimap || !app.loaded || !app.stftComputed) + return (Rectangle){ 0, 0, 0, 0 }; + float scale = GetUIScale(); + float mul = app.minimapLarge ? 2.0f : 1.0f; + float w = MINIMAP_DRAW_W * mul * scale; + float h = MINIMAP_DRAW_H * mul * scale; + float m = MINIMAP_MARGIN * scale; + // Anchored to the top-right: growing extends left and down, so the corner + // it is pinned to never moves. + return (Rectangle){ GetScreenWidth() - w - m, MENUBAR_HEIGHT * scale + m, w, h }; +} + +// The corner grab square, in screen space. Zero-sized when the minimap is +// hidden. Shared by the draw pass and the input phase so they can't disagree +// about where it is. +Rectangle MinimapHandleRect(void) +{ + Rectangle b = MinimapBounds(); + if (b.width <= 0.0f) return (Rectangle){ 0, 0, 0, 0 }; + float hs = MINIMAP_HANDLE * GetUIScale(); + return (Rectangle){ b.x, b.y + b.height - hs, hs, hs }; +} + +// True when the cursor is over the minimap (or a drag that started there is +// still live), so the spectrogram ignores the click. +bool MinimapCapturesMouse(void) +{ + if (app.minimapDragging) return true; + Rectangle b = MinimapBounds(); + if (b.width <= 0.0f) return false; + return CheckCollisionPointRec(GetMousePosition(), b); +} + +Rectangle DrawMinimap(void) +{ + Rectangle box = MinimapBounds(); + if (box.width <= 0.0f) return (Rectangle){ 0, 0, 0, 0 }; + + int slot = app.minimapLarge ? 1 : 0; + float scale = GetUIScale(); + + if (!app.minimapValid[slot]) BuildMinimapTexture(slot); + + DrawRectangleRec(box, (Color){ 12, 12, 16, 235 }); + if (app.minimapTexture[slot].id != 0) { + DrawTexturePro(app.minimapTexture[slot], + (Rectangle){ 0, 0, (float)app.minimapTexture[slot].width, + (float)app.minimapTexture[slot].height }, + box, (Vector2){ 0, 0 }, 0.0f, (Color){ 255, 255, 255, 230 }); + } + DrawRectangleLinesEx(box, 1, (Color){ 110, 110, 130, 255 }); + + // Current view, as a rectangle over the thumbnail. Kept at least a couple + // of pixels wide so a deep zoom still shows a visible marker. + float vx0 = box.x + app.view.start * box.width; + float vx1 = box.x + app.view.end * box.width; + if (vx1 - vx0 < 2.0f) vx1 = vx0 + 2.0f; + Rectangle vr = { vx0, box.y, vx1 - vx0, box.height }; + DrawRectangleRec(vr, (Color){ 255, 255, 255, 40 }); + DrawRectangleLinesEx(vr, 1, (Color){ 255, 255, 255, 200 }); + + // Bottom-left corner handle: click to switch size. Not a drag-resize — + // two fixed sizes means two cached textures and no rebuild on resize. + float hs = MINIMAP_HANDLE * scale; + Rectangle handle = MinimapHandleRect(); + bool overHandle = CheckCollisionPointRec(GetMousePosition(), handle); + DrawRectangleRec(handle, overHandle ? (Color){ 90, 90, 110, 240 } + : (Color){ 50, 50, 62, 220 }); + // Diagonal grip lines, drawn corner-in so it reads as a resize affordance. + for (int i = 1; i <= 2; i++) { + float o = hs * (0.28f * i); + DrawLineEx((Vector2){ handle.x + 2, handle.y + handle.height - o }, + (Vector2){ handle.x + o, handle.y + handle.height - 2 }, + 1.2f, (Color){ 190, 190, 205, 255 }); + } + // The click itself is handled during the input phase (MinimapHandleRect + + // the scrub block in the main loop), which runs before the pan handlers. + // Acting on it here would be too late: the scrub would already have claimed + // the press and the view would jump while the size changed. + return box; +} + void DrawAnnotationsToImage(Image* img, Font font) { if (!app.annotations.loaded || !app.showAnnotations) return; @@ -1953,7 +2199,19 @@ void DrawTimeline(Rectangle lane) qsort(items, nVisible, sizeof(TlSortItem), CmpTlSortDesc); // Helper: time (seconds) -> screen X within plot. - #define TL_X(t) (plot.x + (float)((t) / duration) * plot.width) + // + // Mapped through the VISIBLE view, not the whole file. It used to divide by + // `duration` alone, so the lane always showed the entire capture while the + // spectrogram directly above it showed a zoomed window — the two were only + // ever aligned at full zoom-out, and an event's tick sat nowhere near the + // burst it described. + double tlSpan = (double)(app.view.end - app.view.start); + if (tlSpan < 1e-9) tlSpan = 1e-9; + #define TL_X(t) (plot.x + (float)((((t) / duration) - app.view.start) / tlSpan) * plot.width) + + // Events outside the visible window now project off-plot, so clip the lane + // to its own rect rather than letting them smear across the UI. + BeginScissorMode((int)plot.x, (int)plot.y, (int)plot.width, (int)plot.height); if (!app.timelineExpanded) { // Single mixed strip. Each event is a colored rect spanning [t0,t1] @@ -2023,6 +2281,8 @@ void DrawTimeline(Rectangle lane) if (mouseInChev && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) app.timelineExpanded = !app.timelineExpanded; + EndScissorMode(); + // Click handling. In collapsed mode the lane is small + low-resolution, // so any click in the plot expands rather than risking an accidental // selection. Selection is an "expanded mode" gesture. diff --git a/src/render.h b/src/render.h index 749018a..8c8166a 100644 --- a/src/render.h +++ b/src/render.h @@ -28,6 +28,8 @@ const char* ColormapName(ColormapType type); // reassignment to colors (use for dB-floor / colormap changes). void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture); void ColorizeSpectrogram(Image* image, Texture2D* texture); +// Colormap lookup (0-1 -> colour); used by the sidebar's colormap swatch. +Color GetColormapColor(float t, ColormapType type); // --- Headless (no-GL) spectrogram + annotation rendering --- // BuildSpectrogramImageCPU fills `image` with the colorized spectrogram with no @@ -49,6 +51,19 @@ void DrawMarkers(Rectangle bounds); void DrawSpectrumPanel(Rectangle bounds); void DrawPlayhead(Rectangle bounds); void DrawAnnotations(Rectangle bounds); + +// --- Minimap --- +// Whole-file thumbnail in the top-right corner with a viewport rectangle. +// Returns the rect it occupies (zero-sized when hidden) so the caller can +// route clicks to it. The texture is cached; InvalidateMinimap() forces a +// rebuild on the next draw. +Rectangle DrawMinimap(void); +// Minimap rect without drawing, plus a hit test — needed during input handling, +// which runs long before the draw pass. +Rectangle MinimapBounds(void); +bool MinimapCapturesMouse(void); +Rectangle MinimapHandleRect(void); // corner size-toggle square +void InvalidateMinimap(void); // Recompute which annotations overlap in time+frequency. Call after the // annotation set changes; result is cached in app.collisionFlags/Regions. void ComputeCollisions(void); diff --git a/src/spectrogram.c b/src/spectrogram.c index 54e3d25..c43a58e 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -54,20 +54,34 @@ Font mainFont = {0}; // TTF font for crisp text at any scale * Returns true if the user has pressed any mouse/keyboard input this frame. * Used to gate background processing — we only compute when the user is idle. */ +// Bumped once per main-loop iteration; used to memoize per-frame queries. +static unsigned long long g_frameCounter = 1; + static bool IsUserInteracting(void) { + // Cached per frame: this is called from IsAppActive and from both + // background-fill gates, and the key sweep below is 512 probes. Recomputing + // it three times a frame was pure waste, and GetMouseWheelMove() is also + // read by the zoom and scrollbar handlers, so it must not be consumed here. + static unsigned long long cachedFrame = 0; + static bool cached = false; + if (g_frameCounter == cachedFrame) return cached; + cachedFrame = g_frameCounter; + + cached = true; if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) || IsMouseButtonDown(MOUSE_BUTTON_RIGHT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) { - return true; + return cached; } - // Check for mouse wheel - if (GetMouseWheelMove() != 0) return true; - // Check for key press (key codes are 0..512 in raylib) + if (GetMouseWheelMove() != 0) return cached; + // Any key down this frame. GetKeyPressed() drains raylib's queue (which + // would swallow presses the keymap needs), so probe the range instead. for (int key = 0; key < 512; key++) { - if (IsKeyPressed(key)) return true; + if (IsKeyPressed(key)) return cached; } - return false; + cached = false; + return cached; } // Idle power management. raylib re-renders the whole scene every frame, so an @@ -172,6 +186,7 @@ typedef struct { float vScrollbarWidth; float topMargin; float bottomMargin; + float menubarHeight; // top menubar strip float spectroHeight; // height of the spectrogram (respects the scope divider AND the timeline lane) float timelineHeight; // 0 if no annotations / lane hidden Rectangle viewBounds; // the spectrogram drawing area @@ -192,12 +207,17 @@ static Layout ComputeLayout(void) { Layout L; L.scale = GetUIScale(); - L.sidebarWidth = 320 * L.scale; + // User-resizable via the splitter; collapses to nothing. + L.sidebarWidth = app.sidebarCollapsed ? 0.0f : app.sidebarWidth * L.scale; + L.menubarHeight = MENUBAR_HEIGHT * L.scale; L.labelHeight = 15 * L.scale; L.scrollbarHeight = 22 * L.scale; - L.freqLabelWidth = 65 * L.scale; + // Gutter for the frequency labels. They are right-aligned to the axis and + // compacted ("24k"), so this is comfortably wider than the widest label. + L.freqLabelWidth = 52 * L.scale; L.vScrollbarWidth = 18 * L.scale; - L.topMargin = 50 * L.scale; + // Leaves room for the menubar plus the freq-range banner beneath it. + L.topMargin = 50 * L.scale + L.menubarHeight; L.bottomMargin = 10 * L.scale; L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider(); @@ -206,13 +226,16 @@ static Layout ComputeLayout(void) // by one row per enabled kind. Only present when the file carries // annotations and the master toggle is on. L.timelineHeight = 0; - if (app.annotations.loaded && app.annotations.eventCount > 0 && app.showAnnotations) { + if (app.annotations.loaded && app.annotations.eventCount > 0 && + app.showAnnotations && app.showTimeline) { if (app.timelineExpanded) { + // One row per kind, tall enough that a row is actually clickable + // rather than a hairline. int rows = CountVisibleAnnotationKinds(); if (rows < 1) rows = 1; - L.timelineHeight = (rows * 14.0f + 4.0f) * L.scale; + L.timelineHeight = (rows * 18.0f + 6.0f) * L.scale; } else { - L.timelineHeight = 10.0f * L.scale; + L.timelineHeight = 16.0f * L.scale; } } @@ -292,6 +315,7 @@ void ResetForNewSignal(void) app.collisionCount = 0; app.collisionRegionCount = 0; app.currentCollision = -1; + InvalidateMinimap(); app.jumpCollisionRequest = 0; // Segment range belongs to the previous file's STFT; 0/0 means "whole file" // and lets the first rebuild pick the range for the new one. @@ -544,6 +568,10 @@ static void ActionToggleFullscreen(void){ ToggleFullscreen(); } static void ActionExport(void) { ExportPNG(&app, app.exportDir); } static void ActionExportWav(void) { ExportSelectionWAV(app.exportDir); } static void ActionToggleMarker(void) { app.markerMode = !app.markerMode; } +// Tab flips the default left-drag gesture, the way Blender's Tab swaps +// object/edit mode. Ctrl still means "the other gesture" in either mode, so +// this only moves which one is bare. +static void ActionToggleSelectMode(void) { app.selectMode = !app.selectMode; } static void ActionToggleSpectrum(void) { app.showSpectrum = !app.showSpectrum; } static void ActionResetView(void) @@ -634,6 +662,7 @@ static const KeyBinding KEYMAP[] = { { KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" }, { KEY_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" }, { KEY_M, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker, "M", "marker / ruler tool" }, + { KEY_TAB, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleSelectMode,"Tab", "pan / select mode" }, { KEY_S, KEYGATE_MODAL | KEYGATE_STFT, ActionToggleSpectrum, "S", "spectrum slice (PSD)" }, { KEY_N, KEYGATE_MODAL | KEYGATE_LOADED,ActionCollisionNav, "N", "next collision (Shift+N = prev)" }, // Order-sensitive: handled inline (see main loop), listed here for the overlay. @@ -648,6 +677,48 @@ const KeyBinding* GetKeymap(int* count) } // Run every gated, dispatchable binding whose key was pressed this frame. +// Gate check shared by the key dispatcher and the menubar, so a menu item is +// greyed out under exactly the conditions that make its shortcut a no-op. +bool KeymapActionEnabled(int key) +{ + int n; + const KeyBinding* km = GetKeymap(&n); + for (int i = 0; i < n; i++) { + if (km[i].key != key) continue; + if (!km[i].action) return false; + if ((km[i].gate & KEYGATE_LOADED) && !app.loaded) return false; + if ((km[i].gate & KEYGATE_STFT) && !app.stftComputed) return false; + return true; + } + return false; +} + +// Run a keymap entry by key code. The menubar routes clicks through here so an +// item and its shortcut always do the same thing. KEYGATE_MODAL is deliberately +// not checked: the menubar is itself a UI surface, and the caller only reaches +// this when no modal is up. +void InvokeKeymapAction(int key) +{ + int n; + const KeyBinding* km = GetKeymap(&n); + for (int i = 0; i < n; i++) { + if (km[i].key == key && km[i].action && KeymapActionEnabled(key)) { + km[i].action(); + return; + } + } +} + +// Shortcut label for a key code ("O", "Home", ...), or "" if not bound. +const char* KeymapLabelFor(int key) +{ + int n; + const KeyBinding* km = GetKeymap(&n); + for (int i = 0; i < n; i++) + if (km[i].key == key) return km[i].label; + return ""; +} + static void DispatchKeymap(void) { int n; @@ -964,11 +1035,15 @@ int main(int argc, char* argv[]) // Optional CLI override of the resting overlay alpha (e.g. for a brighter // GUI default). The headless render path sets its own default separately. if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity; + app.showTimeline = false; // opt-in: it costs spectrogram height app.timelineExpanded = false; app.hoveredTimelineEvent = -1; app.selectedAnnotation = -1; app.hoverStackCount = 0; app.currentCollision = -1; + app.sidebarWidth = SIDEBAR_WIDTH_DEFAULT; + app.sidebarCollapsed = false; + app.openMenu = -1; for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true; // Control events are zero-duration log markers about the run, not signals on // the air. On a busy capture there are thousands of them and they clutter @@ -977,7 +1052,9 @@ int main(int argc, char* argv[]) // --render keeps every kind enabled — an export should show what was asked // for, not a GUI default.) app.annotationKindEnabled[MLNL_KIND_CONTROL] = false; - app.showScope = true; + // Off by default: the spectrogram is the primary view and the scope costs + // it a third of the height. Toggle from the rail or with P. + app.showScope = false; app.dividerY = 0.6f; // Start with 60% spectro, 40% scope app.isDividing = false; app.dividerStartPos = (Vector2){ 0, 0 }; @@ -1018,6 +1095,8 @@ int main(int argc, char* argv[]) while (!WindowShouldClose()) { + g_frameCounter++; + // Set when the window is in the background but still has compute to // finish: the frame runs its logic and skips presenting. See the // power-management block below. @@ -1154,10 +1233,44 @@ int main(int argc, char* argv[]) float spectroHeight = L.spectroHeight; Rectangle viewBounds = L.viewBounds; + // Minimap scrub. Runs BEFORE the pan/zoom handlers below so a press + // inside the minimap is claimed here; those handlers are gated on + // MinimapCapturesMouse() and will skip it. Both writing app.view in + // one frame is what made the drag jump around. + { + Rectangle mmBox = MinimapBounds(); + if (mmBox.width > 0.0f && app.signal.duration > 0.0f) { + Vector2 mm = GetMousePosition(); + // Corner handle wins over the scrub: it sits inside the + // minimap, so without this a press there would both resize + // and start dragging the view. + Rectangle mmHandle = MinimapHandleRect(); + bool onHandle = CheckCollisionPointRec(mm, mmHandle); + if (onHandle && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) + app.minimapLarge = !app.minimapLarge; + + bool overMap = !onHandle && CheckCollisionPointRec(mm, mmBox); + if (overMap && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) + app.minimapDragging = true; + if (!IsMouseButtonDown(MOUSE_LEFT_BUTTON)) app.minimapDragging = false; + if (app.minimapDragging) { + float span = app.view.end - app.view.start; + float centre = Clamp((mm.x - mmBox.x) / mmBox.width, 0.0f, 1.0f); + app.view.start = centre - span * 0.5f; + app.view.end = centre + span * 0.5f; + if (app.view.start < 0.0f) { app.view.start = 0.0f; app.view.end = span; } + if (app.view.end > 1.0f) { app.view.end = 1.0f; app.view.start = 1.0f - span; } + app.visibleTextureValid = false; + } + } + } + // Zoom with mouse wheel. Bare wheel zooms both axes together (keeps // the aspect ratio); Shift+wheel is time-only and Ctrl+wheel is // frequency-only, for when you need to stretch one axis alone. - if (GetMousePosition().x > sidebarWidth + 5 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) { + if (GetMousePosition().x > sidebarWidth + 5 && !MenubarCapturesMouse() && + !SidebarCapturesMouse() && !MinimapCapturesMouse() && + CheckCollisionPointRec(GetMousePosition(), viewBounds)) { int wheel = GetMouseWheelMove(); if (wheel != 0) { float zoomFactor = (wheel > 0) ? 0.8f : 1.2f; @@ -1210,9 +1323,21 @@ int main(int argc, char* argv[]) } } - // Pan with Alt+drag or middle mouse button (pans both axes) - bool canPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE); - if (canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + // Pan is the DEFAULT left-drag gesture: dragging the view around is + // what you do constantly, so it gets the bare gesture and selection + // takes the modifier. app.selectMode swaps the two for people who + // are box-selecting repeatedly. Middle-drag always pans regardless + // of mode, and Alt is kept as a legacy pan modifier. + bool selectHeld = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL); + bool lmbPans = (app.selectMode ? selectHeld : !selectHeld) && !app.markerMode; + bool canPan = lmbPans || IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || + IsMouseButtonDown(MOUSE_BUTTON_MIDDLE); + // Never start a pan on a click that belongs to the UI chrome. + bool overView = CheckCollisionPointRec(GetMousePosition(), viewBounds) && + !MenubarCapturesMouse() && !SidebarCapturesMouse() && + !MinimapCapturesMouse(); + if (overView && ((canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) || + IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE))) { app.view.isPanning = true; app.view.panStartPos = GetMousePosition(); app.view.panStart = app.view.start; @@ -1220,7 +1345,8 @@ int main(int argc, char* argv[]) app.view.panFreqStart = app.view.freqStart; app.view.panFreqEnd = app.view.freqEnd; } - if (app.view.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + if (app.view.isPanning && + (IsMouseButtonDown(MOUSE_LEFT_BUTTON) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE))) { float dx = (GetMousePosition().x - app.view.panStartPos.x) / viewBounds.width; float dy = (GetMousePosition().y - app.view.panStartPos.y) / viewBounds.height; float viewWidth = app.view.panEnd - app.view.panStart; @@ -1246,7 +1372,8 @@ int main(int argc, char* argv[]) if (app.view.freqEnd > 1) app.view.freqEnd = 1; app.visibleTextureValid = false; } - if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.view.isPanning = false; + if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON) || + IsMouseButtonReleased(MOUSE_BUTTON_MIDDLE)) app.view.isPanning = false; // ---- Progressive full-resolution fill ---- // After the strided overview loads, the missing segments are filled @@ -1290,6 +1417,7 @@ int main(int argc, char* argv[]) fillingDirty = true; if (app.bgHighResSeg >= app.stft.numSegments) { app.bgFinished = true; + InvalidateMinimap(); // gaps in the thumbnail are now filled TraceLog(LOG_INFO, "Full-res fill complete (%d segments)", app.stft.numSegments); SaveToCache(); // overwrite the overview-only cache entry } @@ -1306,7 +1434,23 @@ int main(int argc, char* argv[]) if (fillingDirty && (app.bgFinished || !IsUserInteracting())) { static double lastFillColorize = 0.0; double now = GetTime(); - if (app.bgFinished || now - lastFillColorize > 0.5) { + + // Throttle by how much work a repaint actually costs. The + // reassignment walks every segment in the built range x every + // bin, with four trig calls per live bin — zoomed out on a + // multi-hour capture that is ~0.5 G bin-visits, and firing it + // twice a second made the UI choppy for the whole sweep while + // showing almost nothing new: with N segments folded into each + // column, freshly-filled segments mostly land in columns that + // are already drawn. Scale the interval with segsPerCol so a + // zoomed-out view refreshes rarely and a zoomed-in one (where + // the range is small and every segment is its own column) stays + // responsive. + int spc = app.reassignSegsPerCol > 0 ? app.reassignSegsPerCol : 1; + double interval = 0.5 * (double)spc; + if (interval > 8.0) interval = 8.0; + + if (app.bgFinished || now - lastFillColorize > interval) { if (app.bgFinished) AutoScaleAmplitude(&app.stft); GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); app.visibleTextureValid = false; @@ -1388,6 +1532,7 @@ int main(int argc, char* argv[]) GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); app.loadingProgress = 1.0f; app.stftComputed = true; + InvalidateMinimap(); app.loadingPhase = 0; // Reset — background processing runs outside this block app.loadingProgress = 0.0f; // Arm the progressive full-res fill from the start of the file. @@ -1471,7 +1616,15 @@ int main(int argc, char* argv[]) float selSpectroHeight = selL.spectroHeight; Rectangle selBounds = selL.viewBounds; Vector2 mousePos = GetMousePosition(); - + + // An open dropdown floats over the spectrogram; a click meant for a menu + // item must not also start a selection or move the divider underneath it. + // Parking the cursor off-screen is enough to make every hit test below + // miss without threading a flag through each one. + if (MenubarCapturesMouse() || SidebarCapturesMouse() || + MinimapCapturesMouse()) mousePos = (Vector2){ -1000, -1000 }; + + // Calculate divider screen position (for hover detection) // Divider is drawn at the BOTTOM of the spectrogram viewport. With the // timeline lane occupying space above the viewport, viewBounds.y was @@ -1488,6 +1641,14 @@ int main(int argc, char* argv[]) else ClearSelection(); } + // Does a left-drag mean "select" this frame? Ctrl inverts whatever + // app.selectMode says, so either mode can do both gestures. Middle-drag + // is always a pan, so it never selects. + bool selCtrl = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL); + bool selSelects = (app.selectMode ? !selCtrl : selCtrl) && + !IsMouseButtonDown(MOUSE_BUTTON_MIDDLE) && + !app.view.isPanning; + // Check if click is inside existing selection (for dragging) bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f || app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f); @@ -1525,8 +1686,14 @@ int main(int argc, char* argv[]) SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); } else if (app.sel.isDragging) { SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging - } else if (hoverInsideSelection) { + } else if (hoverInsideSelection && selSelects) { SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover + } else if (selSelects) { + // Crosshair whenever a left-drag would draw a box, so the two + // gestures are distinguishable without guessing. + SetMouseCursor(MOUSE_CURSOR_CROSSHAIR); + } else if (app.view.isPanning) { + SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); } else { SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow } @@ -1554,7 +1721,7 @@ int main(int argc, char* argv[]) } if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.marker.dragging = false; } - } else { + } else if (selSelects) { if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (clickInsideSelection) { // Start dragging existing selection @@ -1703,8 +1870,15 @@ int main(int argc, char* argv[]) Rectangle viewBounds = L.viewBounds; // Time labels sit just below the spectrogram Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight }; - // Horizontal scrollbar sits below the time labels - Rectangle hScrollbar = { viewBounds.x, viewBounds.y + viewBounds.height + labelHeight + 5 * renderScale, viewBounds.width, scrollbarHeight }; + // Horizontal scrollbar is pinned to the bottom of the window, below the + // scope. It used to sit directly under the time labels, which put it in + // the middle of the layout competing with the scope's resize divider — + // and the scope covered it outright at larger window sizes. It spans + // the whole time axis either way, so the window edge is where it + // belongs. + Rectangle hScrollbar = { viewBounds.x, + GetScreenHeight() - scrollbarHeight - 4 * renderScale, + viewBounds.width, scrollbarHeight }; // Vertical scrollbar sits to the right of the spectrogram Rectangle vScrollbar = { viewBounds.x + viewBounds.width + 5 * renderScale, viewBounds.y, vScrollbarWidth, viewBounds.height }; @@ -1846,6 +2020,56 @@ int main(int argc, char* argv[]) float vThumbY = vScrollbar.y + (vDenom > 1e-6f ? ((1.0f - app.view.freqEnd) / vDenom) * vTravel : 0.0f); Rectangle vThumb = { vScrollbar.x, vThumbY, vScrollbar.width, vThumbH }; + // Wheel over a scrollbar: pan that axis, or zoom it with Shift. + // The spectrogram's own wheel-zoom ignores this region, so the two + // never both fire. Panning moves by a fraction of the visible span, + // which keeps the feel consistent at every zoom level. + { + int sbWheel = GetMouseWheelMove(); + if (sbWheel != 0) { + bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); + bool overH = CheckCollisionPointRec(mouse, hScrollbar); + bool overV = CheckCollisionPointRec(mouse, vScrollbar); + + if (overH) { + if (shiftHeld) { + // Zoom time about the view centre. + float centre = (app.view.start + app.view.end) * 0.5f; + float w = hViewW * ((sbWheel > 0) ? 0.8f : 1.25f); + float minW = MinTimeViewWidth(); + w = Clamp(w, minW, 1.0f); + app.view.start = centre - w * 0.5f; + app.view.end = centre + w * 0.5f; + } else { + float step = hViewW * 0.15f * (sbWheel > 0 ? -1.0f : 1.0f); + app.view.start += step; + app.view.end += step; + } + if (app.view.start < 0.0f) { app.view.end -= app.view.start; app.view.start = 0.0f; } + if (app.view.end > 1.0f) { app.view.start -= (app.view.end - 1.0f); app.view.end = 1.0f; } + if (app.view.start < 0.0f) app.view.start = 0.0f; + app.visibleTextureValid = false; + } else if (overV) { + if (shiftHeld) { + float centre = (app.view.freqStart + app.view.freqEnd) * 0.5f; + float h = vViewH * ((sbWheel > 0) ? 0.8f : 1.25f); + h = Clamp(h, 0.001f, 1.0f); + app.view.freqStart = centre - h * 0.5f; + app.view.freqEnd = centre + h * 0.5f; + } else { + // Wheel up moves toward higher frequencies (the top). + float step = vViewH * 0.15f * (sbWheel > 0 ? 1.0f : -1.0f); + app.view.freqStart += step; + app.view.freqEnd += step; + } + if (app.view.freqStart < 0.0f) { app.view.freqEnd -= app.view.freqStart; app.view.freqStart = 0.0f; } + if (app.view.freqEnd > 1.0f) { app.view.freqStart -= (app.view.freqEnd - 1.0f); app.view.freqEnd = 1.0f; } + if (app.view.freqStart < 0.0f) app.view.freqStart = 0.0f; + app.visibleTextureValid = false; + } + } + } + // Handle scrollbar interaction. A press on the empty track jumps the // view so the thumb re-centers under the cursor, then drags from there. static bool draggingH = false, draggingV = false; @@ -1935,9 +2159,15 @@ int main(int argc, char* argv[]) // Draw waveform scope view underneath the spectrogram if (app.showScope && app.loaded && app.signal.samples != NULL) { - float totalArea = GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale; - float scopeHeight = totalArea * (1.0f - app.dividerY) - 30 * renderScale; - app.scopeView.y = viewBounds.y + viewBounds.height + 30; + // Sits below the time labels; the horizontal scrollbar is pinned + // to the window's bottom edge, so the scope stops just above it + // rather than having to clear it on the way down. + float scopeTop = labelHeight + 8 * renderScale; + float scopeY = viewBounds.y + viewBounds.height + scopeTop; + float scopeBottom = hScrollbar.y - 6 * renderScale; + float scopeHeight = scopeBottom - scopeY; + if (scopeHeight < 20 * renderScale) scopeHeight = 20 * renderScale; + app.scopeView.y = scopeY; app.scopeView.x = viewBounds.x; app.scopeView.width = viewBounds.width; app.scopeView.height = (int)scopeHeight; @@ -2018,6 +2248,13 @@ int main(int argc, char* argv[]) // both happened to be up at once (shouldn't happen in practice). DrawAutocropNotice(); + if (!UiModalOpen()) DrawMinimap(); + + // Sidebar popouts and the menubar float above the main UI but below + // modals: their panels have to paint over the spectrogram and scope. + if (!UiModalOpen()) DrawSidebarPopouts(); + if (!UiModalOpen()) DrawMenubar(); + // About / help dialog (topmost) DrawAboutDialog(); @@ -2054,6 +2291,8 @@ int main(int argc, char* argv[]) FreeAllCacheEntries(&app.fftCache); free(app.reassignBuffer); free(app.collisionFlags); + for (int i = 0; i < 2; i++) + if (app.minimapTexture[i].id != 0) UnloadTexture(app.minimapTexture[i]); FreeWaveEnvelope(&app.scopeView.envelope); FreeMlnl(&app.annotations); FreeSignal(&app.signal); diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index 9986245..bc497bc 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -59,6 +59,35 @@ typedef struct { // How long the auto-crop toast stays up, in seconds of *focused* time. #define AUTOCROP_NOTICE_SECONDS 5.0f +// Sidebar sizing. It is an icon rail: the default width is exactly one icon +// plus its margins, so it costs the spectrogram as little width as possible. +// Settings that need more room open as popouts rather than widening the rail. +// Drag the right edge to resize, or drag past the minimum / double-click to +// collapse. RAIL_ICON is the button size the width is derived from. +#define RAIL_ICON_SIZE 34.0f +#define RAIL_ICON_MARGIN 6.0f +#define SIDEBAR_WIDTH_DEFAULT (RAIL_ICON_SIZE + RAIL_ICON_MARGIN * 2) // 46 +#define SIDEBAR_WIDTH_MIN (RAIL_ICON_SIZE + RAIL_ICON_MARGIN * 2) + +// Menubar across the top of the window. +#define MENUBAR_HEIGHT 22.0f + +// Minimap thumbnail: a heavily downscaled view of the whole capture, rendered +// once into a texture and reused every frame. Small on purpose — it exists to +// show *where* you are, not to be readable, so a coarse blob is fine and keeps +// the one-time build cheap. +// Two sizes, large exactly double small, each with its own cached texture. +// The corner handle switches between them rather than free-resizing, so there +// are only ever two thumbnails to keep valid. +#define MINIMAP_TEX_W 256 +#define MINIMAP_TEX_H 64 +#define MINIMAP_TEX_W_LG 512 +#define MINIMAP_TEX_H_LG 128 +#define MINIMAP_DRAW_W 240.0f +#define MINIMAP_DRAW_H 60.0f +#define MINIMAP_MARGIN 10.0f +#define MINIMAP_HANDLE 12.0f // corner grab square, bottom-left + // How many overlapping annotation boxes the cursor-hit stack retains. Deeper // piles than this are counted but not listed individually (the tooltip says // "+N more"), which keeps a dense pile-up from covering the spectrogram. @@ -311,6 +340,35 @@ typedef struct { char autocropNoticeMsg[256]; float autocropNoticeTimer; // seconds left; counts down only while focused + // Default LMB gesture on the spectrogram. Off (the default) = LMB pans and + // Ctrl+LMB drags a selection box; on = the two swap, so LMB selects. The + // modifier always means "the other one", so either mode can do both without + // going back to the rail. + bool selectMode; + + // Minimap: a cached thumbnail of the whole capture in the top-right corner, + // with the current view drawn as a rectangle on it. Click or drag to + // navigate. The texture is rendered once and only rebuilt when something + // that changes its content does (new file, colormap, annotation/collision + // overlays toggled) — never per frame. + bool showMinimap; + bool minimapLarge; // corner handle toggles small <-> large + // One cached texture per size. Both are built at the same source + // resolution, so switching size never triggers a rebuild — only a content + // change (new file, colormap, overlay toggles) does. + Texture2D minimapTexture[2]; + bool minimapValid[2]; + bool minimapDragging; + + // Icon rail width (fixed — every button is the same size) and its collapse + // state. Collapsing hands the whole window width to the spectrogram. + float sidebarWidth; + bool sidebarCollapsed; + + // Menubar: index of the open menu (-1 = none). Click to open, move across + // to switch, click elsewhere or pick an item to close. + int openMenu; + // Optional mLnL annotations parsed from the loaded WAV (empty if the file // doesn't carry the chunk). The annotations overlay has two surfaces: // 1. A faint always-on draw on the spectrogram (alpha = opacityBase). @@ -353,7 +411,8 @@ typedef struct { // Timeline lane state. The lane is rendered between the freq-range banner // and the spectrogram pixels. Collapsed = single-row sparkline; expanded = // one row per kind currently enabled in the file. - bool timelineExpanded; + bool showTimeline; // lane visible at all (off => spectrogram gets the space) + bool timelineExpanded; // one row per kind instead of a single sparkline int hoveredTimelineEvent; // -1 = none; event index hovered in the lane int selectedAnnotation; // -1 = none; persistent selection from a lane click } SpectrogramApp; @@ -472,6 +531,34 @@ typedef struct { // Returns the keymap table and its entry count (defined in spectrogram.c). const KeyBinding* GetKeymap(int* count); +// ============================================================================ +// Menubar — built on top of the keymap so a menu item and its shortcut can +// never drift apart. Most items name a keymap entry by its raylib key code and +// reuse that entry's action, gate, and shortcut label. Items that toggle a +// simple flag with no binding point at the flag instead; a separator is an +// entry with neither. +// ============================================================================ +typedef struct { + const char* label; // NULL = separator + int key; // keymap key to invoke, or 0 + bool* toggle; // flag to flip when there's no keymap entry, or NULL +} MenuItem; + +typedef struct { + const char* title; + const MenuItem* items; + int itemCount; +} Menu; + +// Returns the menubar definition and its menu count (defined in ui.c). +const Menu* GetMenus(int* count); + +// Run a keymap entry's action by key code, honoring its gate. Used by the +// menubar so clicking an item goes through exactly the same path as the key. +void InvokeKeymapAction(int key); +bool KeymapActionEnabled(int key); // false => draw the item greyed out +const char* KeymapLabelFor(int key); // shortcut text, "" if unbound + // ============================================================================ // Small math helpers (header-inline so every module can use them) // ============================================================================ diff --git a/src/ui.c b/src/ui.c index aa6b9ae..a9de023 100644 --- a/src/ui.c +++ b/src/ui.c @@ -337,275 +337,542 @@ void ExportPNG(const SpectrogramApp* spa, const char* dirPath) } // ===== Sidebar ===== -void DrawSidebar(void) +// ===== 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 sidebarWidth = 300 * scale; - float x = 10 * scale; - float topMargin = 10 * scale; - // Content is laid out top-to-bottom from `y`; shift it up by the scroll - // offset so controls that overflow a short window can be reached. Clicks and - // sliders track automatically since their rects are derived from `y`. - float y = topMargin - app.sidebarScroll; - int fontSize = (int)(12 * scale); + 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; - // Dark sidebar background (fixed; does not scroll) - DrawRectangle(0, 0, (int)(sidebarWidth + 20 * scale), GetScreenHeight(), (Color){ 35, 35, 40, 255 }); - DrawLine((int)(sidebarWidth + 10 * scale), 0, (int)(sidebarWidth + 10 * scale), GetScreenHeight(), GRAY); - - // Title - DrawTextScaled("Spectrogram Controls", x, y, 16, WHITE); y += 28 * scale; + DrawRectangle(0, 0, (int)railW, GetScreenHeight(), (Color){ 35, 35, 40, 255 }); - // FFT Size clicker - DrawTextScaled(TextFormat("FFT: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, 14, LIGHTGRAY); y += 20 * scale; - Rectangle fftMinus = { x, y, 30 * scale, 25 * scale }; - Rectangle fftPlus = { x + sidebarWidth - 40 * scale, y, 30 * scale, 25 * scale }; - if (Clicked(fftMinus)) { - int newFFT = app.fftSize / 2; - if (newFFT >= FFT_SIZE_MIN) { - ChangeFFTSize(newFFT); - } - } - if (Clicked(fftPlus)) { - int newFFT = app.fftSize * 2; - if (newFFT <= FFT_SIZE_MAX) { - ChangeFFTSize(newFFT); - } - } - DrawPanelBox(fftMinus, (Color){ 50, 50, 60, 255 }, GRAY); - DrawTextScaled("-", fftMinus.x + 12 * scale, fftMinus.y + 5 * scale, 18, WHITE); - DrawPanelBox(fftPlus, (Color){ 50, 50, 60, 255 }, GRAY); - DrawTextScaled("+", fftPlus.x + 10 * scale, fftPlus.y + 5 * scale, 18, WHITE); - y += 32 * scale; + // 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; - // Amplitude scale mode toggle (Relative dynamic range vs Absolute dBFS) - Rectangle scaleBtn = { x, y, sidebarWidth - 10 * scale, 22 * scale }; - if (Clicked(scaleBtn)) { - app.amplitudeMode = (app.amplitudeMode == SCALE_RELATIVE) ? SCALE_ABSOLUTE : SCALE_RELATIVE; - AutoScaleAmplitude(&app.stft); // re-derive floor/ceiling for the new mode - needsRegen = true; - } - DrawPanelBox(scaleBtn, (Color){ 50, 50, 60, 255 }, GRAY); - DrawTextScaled(app.amplitudeMode == SCALE_ABSOLUTE ? "Scale: Absolute (dBFS)" : "Scale: Relative", - scaleBtn.x + 8 * scale, scaleBtn.y + 4 * scale, 13, WHITE); - y += 30 * scale; + bool loaded = app.loaded; + bool playing = app.isPlaying; - // Floor control — meaning depends on the scale mode. - Rectangle dbSlider = { x, y + 20 * scale, sidebarWidth - 10 * scale, 20 * scale }; - if (app.amplitudeMode == SCALE_ABSOLUTE) { - DrawTextScaled(TextFormat("Floor: %.1f dBFS", app.absoluteFloorDb), x, y, 14, LIGHTGRAY); - float dbValue = (app.absoluteFloorDb + 100.0f) / 100.0f; // -100..0 dBFS -> 0..1 - DrawSlider(dbSlider, dbValue); - if (UpdateSlider(dbSlider, &dbValue)) { - app.absoluteFloorDb = -100.0f + dbValue * 100.0f; - app.amplitudeCeilingDb = 0.0f; - app.amplitudeFloorDb = app.absoluteFloorDb; - needsRegen = true; - } - } else { - DrawTextScaled(TextFormat("Dyn Range: %.0f dB", app.dynRangeDb), x, y, 14, LIGHTGRAY); - float dbValue = (100.0f - app.dynRangeDb) / 90.0f; // 100..10 dB -> 0..1 (right = more contrast) - DrawSlider(dbSlider, dbValue); - if (UpdateSlider(dbSlider, &dbValue)) { - app.dynRangeDb = 100.0f - dbValue * 90.0f; - app.amplitudeFloorDb = app.amplitudeCeilingDb - app.dynRangeDb; - needsRegen = true; - } - } - y += 48 * scale; - - // Colormap dropdown - DrawTextScaled("Colormap:", x, y, 14, LIGHTGRAY); y += 20 * scale; - Rectangle cmapButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; - if (Clicked(cmapButton)) { - app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); - GenerateColormapTexture(); - needsRegen = true; - } - DrawPanelBox(cmapButton, (Color){ 50, 50, 60, 255 }, GRAY); - DrawTextScaled(ColormapName(app.colormap), cmapButton.x + 10 * scale, cmapButton.y + 6 * scale, 14, WHITE); - DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, - (Rectangle){ cmapButton.x + cmapButton.width - 60 * scale, cmapButton.y + 5 * scale, 50 * scale, 15 * scale }, - (Vector2){ 0, 0 }, 0.0f, WHITE); - y += 32 * scale; - - // Grid toggle - Rectangle gridCheck = { x, y, 18 * scale, 18 * scale }; - if (Clicked(gridCheck)) { - app.showGrid = !app.showGrid; - } - DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE); - DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale; - - // Display freq crop — only meaningful when a signal is loaded. - if (app.loaded && app.signal.sampleRate > 0) { - float nyq = app.signal.sampleRate * 0.5f; - float curr = EffectiveMaxFreqHz(); - DrawTextScaled(TextFormat("Display max: %.0f Hz", curr), x, y, 13, LIGHTGRAY); - // "Auto" button to re-run the auto-crop heuristic (annotations first, - // then energy). Same logic that runs once on file load. - Rectangle autoBtn = { x + sidebarWidth - 58 * scale, y - 2 * scale, - 48 * scale, 16 * scale }; - if (Clicked(autoBtn)) ApplyAutoCrop(); - DrawPanelBox(autoBtn, (Color){ 60, 50, 80, 255 }, (Color){ 160, 130, 200, 255 }); - DrawTextScaled("auto", autoBtn.x + 12 * scale, autoBtn.y + 2 * scale, 10, WHITE); - y += 18 * scale; - Rectangle cropSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale }; - float frac = curr / nyq; - DrawSlider(cropSlider, frac); - if (UpdateSlider(cropSlider, &frac)) { - // Don't let the user crop to a useless sliver — keep at least - // ~2% of the band visible (250 Hz on a 12 kHz file). - if (frac < 0.02f) frac = 0.02f; - app.displayMaxFreqHz = frac * nyq; - // Reset view to show the full cropped range — otherwise a prior - // zoom-into-half-band would magnify even further after a crop. - app.view.freqStart = 0.0f; - app.view.freqEnd = 1.0f; - app.visibleTextureValid = false; - } - y += 22 * scale; - } - - // Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel. - Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale }; - if (Clicked(markerBtn)) app.markerMode = !app.markerMode; - DrawPanelBox(markerBtn, app.markerMode ? (Color){ 110, 70, 30, 255 } : (Color){ 50, 50, 60, 255 }, - app.markerMode ? ORANGE : GRAY); - DrawTextScaled(app.markerMode ? "Marker Tool: ON (M)" : "Marker Tool (M)", - markerBtn.x + 10 * scale, markerBtn.y + 5 * scale, 13, WHITE); - y += 28 * scale; - - Rectangle specBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale }; - if (Clicked(specBtn)) app.showSpectrum = !app.showSpectrum; - DrawPanelBox(specBtn, app.showSpectrum ? (Color){ 30, 70, 90, 255 } : (Color){ 50, 50, 60, 255 }, - app.showSpectrum ? SKYBLUE : GRAY); - DrawTextScaled(app.showSpectrum ? "Spectrum: ON (S)" : "Spectrum Slice (S)", - specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE); - y += 30 * scale; - - // ---- mLnL annotations panel — only when the loaded file has any ---- - if (app.annotations.loaded && app.annotations.eventCount > 0) { - // Left half = master toggle. Right edge = small expand chevron that - // reveals per-kind checkboxes. The chevron is also clickable when the - // master toggle is OFF so the user can configure what to show before - // re-enabling overlays. - Rectangle annBtn = { x, y, (sidebarWidth - 10 * scale) - 28 * scale, 24 * scale }; - Rectangle annExp = { annBtn.x + annBtn.width + 2 * scale, y, 26 * scale, 24 * scale }; - if (Clicked(annBtn)) app.showAnnotations = !app.showAnnotations; - if (Clicked(annExp)) app.annotationsExpanded = !app.annotationsExpanded; - - DrawPanelBox(annBtn, - app.showAnnotations ? (Color){ 70, 40, 90, 255 } : (Color){ 50, 50, 60, 255 }, - app.showAnnotations ? (Color){ 200, 160, 255, 255 } : GRAY); - DrawTextScaled(app.showAnnotations ? "Annotations: ON" : "Annotations: off", - annBtn.x + 10 * scale, annBtn.y + 5 * scale, 13, WHITE); - DrawPanelBox(annExp, (Color){ 50, 50, 60, 255 }, GRAY); - DrawTextScaled(app.annotationsExpanded ? "v" : ">", - annExp.x + 9 * scale, annExp.y + 5 * scale, 13, WHITE); - y += 28 * scale; - - // Collision overlay toggle + summary. Only meaningful once something - // actually overlaps, so the row is hidden when nothing does. - if (app.collisionCount > 0) { - Rectangle colBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale }; - if (Clicked(colBtn)) app.showCollisions = !app.showCollisions; - DrawPanelBox(colBtn, - app.showCollisions ? (Color){ 90, 35, 35, 255 } : (Color){ 50, 50, 60, 255 }, - app.showCollisions ? (Color){ 255, 120, 120, 255 } : GRAY); - DrawTextScaled(app.showCollisions ? "Collisions: ON" : "Collisions: off", - colBtn.x + 10 * scale, colBtn.y + 5 * scale, 13, WHITE); - y += 26 * scale; - - DrawTextScaled(TextFormat("%d events in %d regions", - app.collisionCount, app.collisionRegionCount), - x + 8 * scale, y, 11, (Color){ 255, 150, 150, 255 }); - y += 16 * scale; - - // Prev/next jump. Mirrors the N / Shift+N bindings; having both - // means the feature is findable without reading the help overlay. - float half = (sidebarWidth - 14 * scale) * 0.5f; - Rectangle prevBtn = { x, y, half, 22 * scale }; - Rectangle nextBtn = { x + half + 4 * scale, y, half, 22 * scale }; - if (Clicked(prevBtn)) app.jumpCollisionRequest = -1; - if (Clicked(nextBtn)) app.jumpCollisionRequest = 1; - DrawPanelBox(prevBtn, (Color){ 55, 40, 40, 255 }, GRAY); - DrawPanelBox(nextBtn, (Color){ 55, 40, 40, 255 }, GRAY); - DrawTextScaled("< prev", prevBtn.x + 8 * scale, prevBtn.y + 4 * scale, 12, LIGHTGRAY); - DrawTextScaled("next >", nextBtn.x + 8 * scale, nextBtn.y + 4 * scale, 12, LIGHTGRAY); - y += 26 * scale; - - if (app.currentCollision >= 0) { - DrawTextScaled(TextFormat("at %d/%d", app.currentCollision + 1, - app.collisionRegionCount), - x + 8 * scale, y, 11, GRAY); - y += 16 * scale; - } - } - - - if (app.annotationsExpanded) { - // Two opacity sliders: the spectrogram overlay is drawn at the - // "Base" alpha by default, and bumps to "Highlight" for any event - // that's hovered or selected in the timeline lane. Outlines and - // labels scale together with the fill so the whole overlay fades - // as one unit. - DrawTextScaled(TextFormat("Base: %d%%", (int)(app.annotationOpacityBase * 100.0f)), - x + 8 * scale, y, 12, LIGHTGRAY); - y += 16 * scale; - Rectangle baseSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale }; - DrawSlider(baseSlider, app.annotationOpacityBase); - UpdateSlider(baseSlider, &app.annotationOpacityBase); - y += 20 * scale; - - DrawTextScaled(TextFormat("Highlight: %d%%", (int)(app.annotationOpacityHover * 100.0f)), - x + 8 * scale, y, 12, LIGHTGRAY); - y += 16 * scale; - Rectangle hovSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale }; - DrawSlider(hovSlider, app.annotationOpacityHover); - UpdateSlider(hovSlider, &app.annotationOpacityHover); - y += 22 * scale; - - // Indented checkbox per kind actually present in this file. Order: - // walk the enum so the menu order is stable across files. - for (int k = 0; k < MLNL_KIND_MAX; k++) { - if (!app.annotations.kindPresent[k]) continue; - Rectangle cb = { x + 8 * scale, y + 2 * scale, 14 * scale, 14 * scale }; - if (Clicked(cb)) app.annotationKindEnabled[k] = !app.annotationKindEnabled[k]; - DrawPanelBox(cb, app.annotationKindEnabled[k] ? (Color){ 140, 100, 200, 255 } : DARKGRAY, - LIGHTGRAY); - DrawTextScaled(MlnlKindName((MlnlKind)k), - cb.x + 22 * scale, cb.y - 1 * scale, 12, LIGHTGRAY); - y += 18 * scale; - } - y += 4 * scale; - } - if (app.annotations.truncated) { - DrawTextScaled("(file truncated)", x + 4 * scale, y, 11, - (Color){ 255, 180, 180, 255 }); - y += 16 * scale; - } - y += 6 * scale; - } - - // File loading - DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale; - Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; - if (Clicked(fileButton)) { - app.showFileBrowser = true; - ScanDirectory(GetWorkingDirectory()); - } - DrawPanelBox(fileButton, (Color){ 50, 50, 60, 255 }, GRAY); - DrawTextScaled("Open File Browser (O)", fileButton.x + 10 * scale, fileButton.y + 6 * scale, 14, WHITE); - y += 38 * scale; - - // Playback - Rectangle playButton = { x, y, sidebarWidth - 10 * scale, 35 * scale }; - if (Clicked(playButton)) { - if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { + // --- 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 { @@ -613,107 +880,136 @@ void DrawSidebar(void) app.isPlaying = true; } } - const char* playText = app.isPlaying ? "STOP (SPACE)" : "PLAY (SPACE)"; - DrawPanelBox(playButton, app.isPlaying ? (Color){ 120, 40, 40, 255 } : (Color){ 40, 100, 40, 255 }, - app.isPlaying ? RED : GREEN); - DrawTextScaled(playText, playButton.x + 10 * scale, playButton.y + 12 * scale, 14, WHITE); - y += 48 * scale; + y += btn + gap; - // Fullscreen toggle - Rectangle fsButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; - bool isFullscreen = IsWindowFullscreen(); - if (Clicked(fsButton)) { - ToggleFullscreen(); - } - DrawPanelBox(fsButton, isFullscreen ? (Color){ 40, 80, 120, 255 } : (Color){ 50, 50, 60, 255 }, GRAY); - DrawTextScaled(isFullscreen ? "Exit Fullscreen (F11)" : "Fullscreen (F11)", fsButton.x + 10 * scale, fsButton.y + 6 * scale, 14, WHITE); - y += 38 * scale; - - // Clear selection (identical to the Esc key) - Rectangle clearButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; - if (Clicked(clearButton)) { + Rectangle rClear = { bx, y, btn, btn }; + if (RailButton(rClear, IconClear, false, loaded, "Clear selection (Esc)", scale)) ClearSelection(); - } - DrawPanelBox(clearButton, (Color){ 80, 50, 50, 255 }, RED); - DrawTextScaled("Clear Selection (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE); - y += 38 * scale; + y += btn + gap * 2.4f; - // Export PNG - { - Rectangle exportButton = { x, y, sidebarWidth - 10 * scale, 30 * scale }; - if (Clicked(exportButton)) { - ExportPNG(&app, app.exportDir); + // --- 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(); } - DrawPanelBox(exportButton, (Color){ 40, 60, 80, 255 }, CYAN); - DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.y + 7 * scale, 14, WHITE); - y += 35 * scale; - - // Export selection audio (band-limited, time-cropped) as a WAV - Rectangle wavButton = { x, y, sidebarWidth - 10 * scale, 30 * scale }; - if (Clicked(wavButton)) { - ExportSelectionWAV(app.exportDir); + // 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; } - DrawPanelBox(wavButton, (Color){ 40, 60, 80, 255 }, CYAN); - DrawTextScaled("Export WAV (W)", wavButton.x + 10 * scale, wavButton.y + 7 * scale, 14, WHITE); - y += 35 * scale; + y += btn + gap; - // Export scale slider - DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale; - Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale }; - DrawSlider(scaleSlider, app.exportScale / 10.0f); - float scaleVal = 0.0f; - if (UpdateSlider(scaleSlider, &scaleVal)) { - app.exportScale = scaleVal * 10.0f; + 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 += 24 * scale; + 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; } - // Signal info - DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale; - if (app.loaded) { - DrawTextScaled(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, 14, GRAY); y += 18 * scale; - DrawTextScaled(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, 14, GRAY); y += 18 * scale; - DrawTextScaled(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, 14, GRAY); y += 18 * scale; - } else { - DrawTextScaled("No file loaded", x, y, 14, GRAY); y += 18 * scale; + // 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; } - y += 10 * scale; - - // About / Help button - Rectangle aboutBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale }; - if (Clicked(aboutBtn)) { - app.showAbout = true; - } - DrawPanelBox(aboutBtn, (Color){ 50, 50, 60, 255 }, GRAY); - DrawTextScaled("About / Help (F1)", aboutBtn.x + 8 * scale, aboutBtn.y + 5 * scale, 13, WHITE); - y += 30 * scale; 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; // Force cache invalidation + app.visibleTextureValid = false; } - - // ---- Scroll handling ---- - // `y` now sits at the bottom of the laid-out content (in screen space). - // Its natural position (scroll == 0) is y + sidebarScroll; allow scrolling - // until that bottom, plus a small pad, reaches the window's bottom edge. - float contentBottom = y + app.sidebarScroll; - float maxScroll = contentBottom + topMargin - GetScreenHeight(); - if (maxScroll < 0) maxScroll = 0; - - // Wheel scrolls the sidebar only when the cursor is over it and no modal is - // up. The spectrogram's own wheel-zoom already ignores the sidebar column, - // so the two never both fire. - bool overSidebar = GetMousePosition().x < (sidebarWidth + 20 * scale); - if (overSidebar && !UiModalOpen()) { - float wheel = GetMouseWheelMove(); - if (wheel != 0.0f) app.sidebarScroll -= wheel * 40 * scale; - } - if (app.sidebarScroll > maxScroll) app.sidebarScroll = maxScroll; - if (app.sidebarScroll < 0.0f) app.sidebarScroll = 0.0f; } + static void DrawSlider(Rectangle bounds, float value) { // Background @@ -909,3 +1205,156 @@ void DrawAutocropNotice(void) 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; + } +} diff --git a/src/ui.h b/src/ui.h index f2db198..af31ac0 100644 --- a/src/ui.h +++ b/src/ui.h @@ -9,8 +9,18 @@ void ScanDirectory(const char* path); void FreeBrowserFiles(void); void DrawFileBrowser(void); +// --- Menubar --- +void DrawMenubar(void); +// True when the cursor is over the menubar or an open dropdown, so the +// spectrogram doesn't also act on a click meant for a menu. +bool MenubarCapturesMouse(void); + // --- Sidebar --- void DrawSidebar(void); +// True when the cursor is over the icon rail or one of its popout panels. +bool SidebarCapturesMouse(void); +// Rail popouts, drawn late so they float above the spectrogram and scope. +void DrawSidebarPopouts(void); // --- PNG export --- void ExportPNG(const SpectrogramApp* spa, const char* dirPath);