feat: menubar + icon rail, minimap, Blender-style navigation

A pass over the whole interface, driven by using it on a 5.7-hour capture.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
This commit is contained in:
2026-08-12 15:28:44 -07:00
parent 82294844dd
commit cce96659b1
7 changed files with 1499 additions and 401 deletions
+45 -7
View File
@@ -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)
+281 -21
View File
@@ -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.
+15
View File
@@ -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);
+266 -27
View File
@@ -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.
@@ -1472,6 +1617,14 @@ int main(int argc, char* argv[])
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);
+88 -1
View File
@@ -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)
// ============================================================================
+793 -344
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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);