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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+267
-28
@@ -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);
|
||||
|
||||
+88
-1
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user