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
|
### 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 |
|
| 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) |
|
| **Mouse wheel** | Zoom both axes (preserves aspect ratio) |
|
||||||
| **Shift+wheel** | Zoom the time axis only |
|
| **Shift+wheel** | Zoom the time axis only |
|
||||||
| **Ctrl+wheel** | Zoom the frequency axis only |
|
| **Ctrl+wheel** | Zoom the frequency axis only |
|
||||||
| **Alt+drag** / **middle-drag** | Pan the view |
|
| **Wheel on a scrollbar** | Pan that axis (**Shift** to zoom it) |
|
||||||
| **LMB drag** | Select a time + frequency region |
|
|
||||||
| **Space** | Play / stop the selected region |
|
| **Space** | Play / stop the selected region |
|
||||||
| **Hover an annotation** | Tooltip with that frame's mLnL detail; lists **every** overlapping frame under the cursor |
|
| **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 |
|
| **N** / **Shift+N** | Jump to the next / previous collision |
|
||||||
|
| **O** | Open file browser |
|
||||||
| **P** | Show / hide the waveform scope |
|
| **P** | Show / hide the waveform scope |
|
||||||
| **M** | Marker / ruler tool |
|
| **M** | Marker / ruler tool |
|
||||||
| **S** | Spectrum slice (PSD) |
|
| **S** | Spectrum slice (PSD) |
|
||||||
@@ -148,8 +155,30 @@ or pressing **O** for the file browser. Try the bundled sample:
|
|||||||
| **F1** | About / help |
|
| **F1** | About / help |
|
||||||
| **Esc** | Clear selection / close dialog |
|
| **Esc** | Clear selection / close dialog |
|
||||||
|
|
||||||
Most controls are also available as buttons in the left sidebar (colormap, floor,
|
### Layout
|
||||||
dynamic range, annotation opacity, grid, …).
|
|
||||||
|
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
|
### Inspecting overlapping transmissions
|
||||||
|
|
||||||
@@ -254,6 +283,14 @@ paths.
|
|||||||
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
|
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
|
||||||
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
|
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
|
||||||
colour = amplitude.
|
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
|
- **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
|
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
|
(capped at 8192 px wide), so a multi-hour capture renders at all — an
|
||||||
@@ -285,8 +322,9 @@ paths.
|
|||||||
src/
|
src/
|
||||||
spectrogram.c # entry point, main loop, CLI args, headless render
|
spectrogram.c # entry point, main loop, CLI args, headless render
|
||||||
stft.c / fft.c # STFT + FFT
|
stft.c / fft.c # STFT + FFT
|
||||||
render.c # spectrogram, annotations, tooltips, scope
|
render.c # spectrogram, annotations, tooltips, minimap, scope
|
||||||
ui.c # sidebar, file browser, buttons
|
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
|
audio.c # WAV load (ffmpeg fallback), bandpass, playback, WAV export
|
||||||
mlnl.c / mlnl.h # mLnL annotation chunk parser
|
mlnl.c / mlnl.h # mLnL annotation chunk parser
|
||||||
platform_*.c # per-OS shims (linux / win32 / web)
|
platform_*.c # per-OS shims (linux / win32 / web)
|
||||||
|
|||||||
+281
-21
@@ -160,7 +160,7 @@ static const ColormapDef COLORMAPS[COLORMAP_COUNT] = {
|
|||||||
[COLORMAP_COOL] = { "Cool", CmapCool },
|
[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;
|
if (type < 0 || type >= COLORMAP_COUNT) return GRAY;
|
||||||
return COLORMAPS[type].fn(Clamp(t, 0.0f, 1.0f));
|
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);
|
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) {
|
for (int hz = firstTick; hz <= freqMax; hz += labelSpacing) {
|
||||||
float t = (hz - freqMin) / freqRange;
|
float t = (hz - freqMin) / freqRange;
|
||||||
float y = bounds.y + bounds.height - t * bounds.height;
|
float y = bounds.y + bounds.height - t * bounds.height;
|
||||||
char label[32];
|
char label[32];
|
||||||
if (hz < 10000) sprintf(label, "%.0fHz", (float)hz);
|
if (!useKilo) sprintf(label, "%d", hz);
|
||||||
else sprintf(label, "%.0fkHz", (float)hz / 1000.0f);
|
else if (wholeKilo) sprintf(label, "%dk", hz / 1000);
|
||||||
DrawTextScaled(label, bounds.x - 70, y - 5, baseFontSize, textColor);
|
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;
|
app.hoverStackCount = stackCount;
|
||||||
for (int i = 0; i < stackCount; i++) app.hoverStack[i] = stack[i];
|
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 ----
|
// ---- Collision overlay ----
|
||||||
// Marks where transmissions genuinely overlap in time AND frequency. Drawn
|
// 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
|
// 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) {
|
if (app.annotations.truncated) {
|
||||||
const char* msg = "mLnL: truncated";
|
const char* msg = "mLnL: truncated";
|
||||||
float fs = 10.0f;
|
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).
|
// 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)
|
void DrawAnnotationsToImage(Image* img, Font font)
|
||||||
{
|
{
|
||||||
if (!app.annotations.loaded || !app.showAnnotations) return;
|
if (!app.annotations.loaded || !app.showAnnotations) return;
|
||||||
@@ -1953,7 +2199,19 @@ void DrawTimeline(Rectangle lane)
|
|||||||
qsort(items, nVisible, sizeof(TlSortItem), CmpTlSortDesc);
|
qsort(items, nVisible, sizeof(TlSortItem), CmpTlSortDesc);
|
||||||
|
|
||||||
// Helper: time (seconds) -> screen X within plot.
|
// 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) {
|
if (!app.timelineExpanded) {
|
||||||
// Single mixed strip. Each event is a colored rect spanning [t0,t1]
|
// 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))
|
if (mouseInChev && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
|
||||||
app.timelineExpanded = !app.timelineExpanded;
|
app.timelineExpanded = !app.timelineExpanded;
|
||||||
|
|
||||||
|
EndScissorMode();
|
||||||
|
|
||||||
// Click handling. In collapsed mode the lane is small + low-resolution,
|
// Click handling. In collapsed mode the lane is small + low-resolution,
|
||||||
// so any click in the plot expands rather than risking an accidental
|
// so any click in the plot expands rather than risking an accidental
|
||||||
// selection. Selection is an "expanded mode" gesture.
|
// 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).
|
// reassignment to colors (use for dB-floor / colormap changes).
|
||||||
void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture);
|
void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture);
|
||||||
void ColorizeSpectrogram(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 ---
|
// --- Headless (no-GL) spectrogram + annotation rendering ---
|
||||||
// BuildSpectrogramImageCPU fills `image` with the colorized spectrogram with no
|
// BuildSpectrogramImageCPU fills `image` with the colorized spectrogram with no
|
||||||
@@ -49,6 +51,19 @@ void DrawMarkers(Rectangle bounds);
|
|||||||
void DrawSpectrumPanel(Rectangle bounds);
|
void DrawSpectrumPanel(Rectangle bounds);
|
||||||
void DrawPlayhead(Rectangle bounds);
|
void DrawPlayhead(Rectangle bounds);
|
||||||
void DrawAnnotations(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
|
// Recompute which annotations overlap in time+frequency. Call after the
|
||||||
// annotation set changes; result is cached in app.collisionFlags/Regions.
|
// annotation set changes; result is cached in app.collisionFlags/Regions.
|
||||||
void ComputeCollisions(void);
|
void ComputeCollisions(void);
|
||||||
|
|||||||
+266
-27
@@ -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.
|
* 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.
|
* 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)
|
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) ||
|
if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) ||
|
||||||
IsMouseButtonDown(MOUSE_BUTTON_RIGHT) ||
|
IsMouseButtonDown(MOUSE_BUTTON_RIGHT) ||
|
||||||
IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) {
|
IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) {
|
||||||
return true;
|
return cached;
|
||||||
}
|
}
|
||||||
// Check for mouse wheel
|
if (GetMouseWheelMove() != 0) return cached;
|
||||||
if (GetMouseWheelMove() != 0) return true;
|
// Any key down this frame. GetKeyPressed() drains raylib's queue (which
|
||||||
// Check for key press (key codes are 0..512 in raylib)
|
// would swallow presses the keymap needs), so probe the range instead.
|
||||||
for (int key = 0; key < 512; key++) {
|
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
|
// Idle power management. raylib re-renders the whole scene every frame, so an
|
||||||
@@ -172,6 +186,7 @@ typedef struct {
|
|||||||
float vScrollbarWidth;
|
float vScrollbarWidth;
|
||||||
float topMargin;
|
float topMargin;
|
||||||
float bottomMargin;
|
float bottomMargin;
|
||||||
|
float menubarHeight; // top menubar strip
|
||||||
float spectroHeight; // height of the spectrogram (respects the scope divider AND the timeline lane)
|
float spectroHeight; // height of the spectrogram (respects the scope divider AND the timeline lane)
|
||||||
float timelineHeight; // 0 if no annotations / lane hidden
|
float timelineHeight; // 0 if no annotations / lane hidden
|
||||||
Rectangle viewBounds; // the spectrogram drawing area
|
Rectangle viewBounds; // the spectrogram drawing area
|
||||||
@@ -192,12 +207,17 @@ static Layout ComputeLayout(void)
|
|||||||
{
|
{
|
||||||
Layout L;
|
Layout L;
|
||||||
L.scale = GetUIScale();
|
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.labelHeight = 15 * L.scale;
|
||||||
L.scrollbarHeight = 22 * 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.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.bottomMargin = 10 * L.scale;
|
||||||
L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();
|
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
|
// by one row per enabled kind. Only present when the file carries
|
||||||
// annotations and the master toggle is on.
|
// annotations and the master toggle is on.
|
||||||
L.timelineHeight = 0;
|
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) {
|
if (app.timelineExpanded) {
|
||||||
|
// One row per kind, tall enough that a row is actually clickable
|
||||||
|
// rather than a hairline.
|
||||||
int rows = CountVisibleAnnotationKinds();
|
int rows = CountVisibleAnnotationKinds();
|
||||||
if (rows < 1) rows = 1;
|
if (rows < 1) rows = 1;
|
||||||
L.timelineHeight = (rows * 14.0f + 4.0f) * L.scale;
|
L.timelineHeight = (rows * 18.0f + 6.0f) * L.scale;
|
||||||
} else {
|
} else {
|
||||||
L.timelineHeight = 10.0f * L.scale;
|
L.timelineHeight = 16.0f * L.scale;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +315,7 @@ void ResetForNewSignal(void)
|
|||||||
app.collisionCount = 0;
|
app.collisionCount = 0;
|
||||||
app.collisionRegionCount = 0;
|
app.collisionRegionCount = 0;
|
||||||
app.currentCollision = -1;
|
app.currentCollision = -1;
|
||||||
|
InvalidateMinimap();
|
||||||
app.jumpCollisionRequest = 0;
|
app.jumpCollisionRequest = 0;
|
||||||
// Segment range belongs to the previous file's STFT; 0/0 means "whole file"
|
// 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.
|
// 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 ActionExport(void) { ExportPNG(&app, app.exportDir); }
|
||||||
static void ActionExportWav(void) { ExportSelectionWAV(app.exportDir); }
|
static void ActionExportWav(void) { ExportSelectionWAV(app.exportDir); }
|
||||||
static void ActionToggleMarker(void) { app.markerMode = !app.markerMode; }
|
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 ActionToggleSpectrum(void) { app.showSpectrum = !app.showSpectrum; }
|
||||||
|
|
||||||
static void ActionResetView(void)
|
static void ActionResetView(void)
|
||||||
@@ -634,6 +662,7 @@ static const KeyBinding KEYMAP[] = {
|
|||||||
{ KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" },
|
{ KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" },
|
||||||
{ KEY_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" },
|
{ KEY_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" },
|
||||||
{ KEY_M, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker, "M", "marker / ruler tool" },
|
{ 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_S, KEYGATE_MODAL | KEYGATE_STFT, ActionToggleSpectrum, "S", "spectrum slice (PSD)" },
|
||||||
{ KEY_N, KEYGATE_MODAL | KEYGATE_LOADED,ActionCollisionNav, "N", "next collision (Shift+N = prev)" },
|
{ 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.
|
// 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.
|
// 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)
|
static void DispatchKeymap(void)
|
||||||
{
|
{
|
||||||
int n;
|
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
|
// Optional CLI override of the resting overlay alpha (e.g. for a brighter
|
||||||
// GUI default). The headless render path sets its own default separately.
|
// GUI default). The headless render path sets its own default separately.
|
||||||
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
|
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
|
||||||
|
app.showTimeline = false; // opt-in: it costs spectrogram height
|
||||||
app.timelineExpanded = false;
|
app.timelineExpanded = false;
|
||||||
app.hoveredTimelineEvent = -1;
|
app.hoveredTimelineEvent = -1;
|
||||||
app.selectedAnnotation = -1;
|
app.selectedAnnotation = -1;
|
||||||
app.hoverStackCount = 0;
|
app.hoverStackCount = 0;
|
||||||
app.currentCollision = -1;
|
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;
|
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
|
// 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
|
// 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
|
// --render keeps every kind enabled — an export should show what was asked
|
||||||
// for, not a GUI default.)
|
// for, not a GUI default.)
|
||||||
app.annotationKindEnabled[MLNL_KIND_CONTROL] = false;
|
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.dividerY = 0.6f; // Start with 60% spectro, 40% scope
|
||||||
app.isDividing = false;
|
app.isDividing = false;
|
||||||
app.dividerStartPos = (Vector2){ 0, 0 };
|
app.dividerStartPos = (Vector2){ 0, 0 };
|
||||||
@@ -1018,6 +1095,8 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
while (!WindowShouldClose())
|
while (!WindowShouldClose())
|
||||||
{
|
{
|
||||||
|
g_frameCounter++;
|
||||||
|
|
||||||
// Set when the window is in the background but still has compute to
|
// Set when the window is in the background but still has compute to
|
||||||
// finish: the frame runs its logic and skips presenting. See the
|
// finish: the frame runs its logic and skips presenting. See the
|
||||||
// power-management block below.
|
// power-management block below.
|
||||||
@@ -1154,10 +1233,44 @@ int main(int argc, char* argv[])
|
|||||||
float spectroHeight = L.spectroHeight;
|
float spectroHeight = L.spectroHeight;
|
||||||
Rectangle viewBounds = L.viewBounds;
|
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
|
// Zoom with mouse wheel. Bare wheel zooms both axes together (keeps
|
||||||
// the aspect ratio); Shift+wheel is time-only and Ctrl+wheel is
|
// the aspect ratio); Shift+wheel is time-only and Ctrl+wheel is
|
||||||
// frequency-only, for when you need to stretch one axis alone.
|
// 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();
|
int wheel = GetMouseWheelMove();
|
||||||
if (wheel != 0) {
|
if (wheel != 0) {
|
||||||
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
|
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)
|
// Pan is the DEFAULT left-drag gesture: dragging the view around is
|
||||||
bool canPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
|
// what you do constantly, so it gets the bare gesture and selection
|
||||||
if (canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
// 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.isPanning = true;
|
||||||
app.view.panStartPos = GetMousePosition();
|
app.view.panStartPos = GetMousePosition();
|
||||||
app.view.panStart = app.view.start;
|
app.view.panStart = app.view.start;
|
||||||
@@ -1220,7 +1345,8 @@ int main(int argc, char* argv[])
|
|||||||
app.view.panFreqStart = app.view.freqStart;
|
app.view.panFreqStart = app.view.freqStart;
|
||||||
app.view.panFreqEnd = app.view.freqEnd;
|
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 dx = (GetMousePosition().x - app.view.panStartPos.x) / viewBounds.width;
|
||||||
float dy = (GetMousePosition().y - app.view.panStartPos.y) / viewBounds.height;
|
float dy = (GetMousePosition().y - app.view.panStartPos.y) / viewBounds.height;
|
||||||
float viewWidth = app.view.panEnd - app.view.panStart;
|
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;
|
if (app.view.freqEnd > 1) app.view.freqEnd = 1;
|
||||||
app.visibleTextureValid = false;
|
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 ----
|
// ---- Progressive full-resolution fill ----
|
||||||
// After the strided overview loads, the missing segments are filled
|
// After the strided overview loads, the missing segments are filled
|
||||||
@@ -1290,6 +1417,7 @@ int main(int argc, char* argv[])
|
|||||||
fillingDirty = true;
|
fillingDirty = true;
|
||||||
if (app.bgHighResSeg >= app.stft.numSegments) {
|
if (app.bgHighResSeg >= app.stft.numSegments) {
|
||||||
app.bgFinished = true;
|
app.bgFinished = true;
|
||||||
|
InvalidateMinimap(); // gaps in the thumbnail are now filled
|
||||||
TraceLog(LOG_INFO, "Full-res fill complete (%d segments)", app.stft.numSegments);
|
TraceLog(LOG_INFO, "Full-res fill complete (%d segments)", app.stft.numSegments);
|
||||||
SaveToCache(); // overwrite the overview-only cache entry
|
SaveToCache(); // overwrite the overview-only cache entry
|
||||||
}
|
}
|
||||||
@@ -1306,7 +1434,23 @@ int main(int argc, char* argv[])
|
|||||||
if (fillingDirty && (app.bgFinished || !IsUserInteracting())) {
|
if (fillingDirty && (app.bgFinished || !IsUserInteracting())) {
|
||||||
static double lastFillColorize = 0.0;
|
static double lastFillColorize = 0.0;
|
||||||
double now = GetTime();
|
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);
|
if (app.bgFinished) AutoScaleAmplitude(&app.stft);
|
||||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||||
app.visibleTextureValid = false;
|
app.visibleTextureValid = false;
|
||||||
@@ -1388,6 +1532,7 @@ int main(int argc, char* argv[])
|
|||||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||||
app.loadingProgress = 1.0f;
|
app.loadingProgress = 1.0f;
|
||||||
app.stftComputed = true;
|
app.stftComputed = true;
|
||||||
|
InvalidateMinimap();
|
||||||
app.loadingPhase = 0; // Reset — background processing runs outside this block
|
app.loadingPhase = 0; // Reset — background processing runs outside this block
|
||||||
app.loadingProgress = 0.0f;
|
app.loadingProgress = 0.0f;
|
||||||
// Arm the progressive full-res fill from the start of the file.
|
// 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;
|
Rectangle selBounds = selL.viewBounds;
|
||||||
Vector2 mousePos = GetMousePosition();
|
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)
|
// Calculate divider screen position (for hover detection)
|
||||||
// Divider is drawn at the BOTTOM of the spectrogram viewport. With the
|
// Divider is drawn at the BOTTOM of the spectrogram viewport. With the
|
||||||
// timeline lane occupying space above the viewport, viewBounds.y was
|
// timeline lane occupying space above the viewport, viewBounds.y was
|
||||||
@@ -1488,6 +1641,14 @@ int main(int argc, char* argv[])
|
|||||||
else ClearSelection();
|
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)
|
// Check if click is inside existing selection (for dragging)
|
||||||
bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
|
bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
|
||||||
app.sel.freqStart > 0.001f || app.sel.freqEnd < 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);
|
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL);
|
||||||
} else if (app.sel.isDragging) {
|
} else if (app.sel.isDragging) {
|
||||||
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging
|
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
|
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 {
|
} else {
|
||||||
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
|
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;
|
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.marker.dragging = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (selSelects) {
|
||||||
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||||
if (clickInsideSelection) {
|
if (clickInsideSelection) {
|
||||||
// Start dragging existing selection
|
// Start dragging existing selection
|
||||||
@@ -1703,8 +1870,15 @@ int main(int argc, char* argv[])
|
|||||||
Rectangle viewBounds = L.viewBounds;
|
Rectangle viewBounds = L.viewBounds;
|
||||||
// Time labels sit just below the spectrogram
|
// Time labels sit just below the spectrogram
|
||||||
Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight };
|
Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight };
|
||||||
// Horizontal scrollbar sits below the time labels
|
// Horizontal scrollbar is pinned to the bottom of the window, below the
|
||||||
Rectangle hScrollbar = { viewBounds.x, viewBounds.y + viewBounds.height + labelHeight + 5 * renderScale, viewBounds.width, scrollbarHeight };
|
// 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
|
// Vertical scrollbar sits to the right of the spectrogram
|
||||||
Rectangle vScrollbar = { viewBounds.x + viewBounds.width + 5 * renderScale, viewBounds.y, vScrollbarWidth, viewBounds.height };
|
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);
|
float vThumbY = vScrollbar.y + (vDenom > 1e-6f ? ((1.0f - app.view.freqEnd) / vDenom) * vTravel : 0.0f);
|
||||||
Rectangle vThumb = { vScrollbar.x, vThumbY, vScrollbar.width, vThumbH };
|
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
|
// Handle scrollbar interaction. A press on the empty track jumps the
|
||||||
// view so the thumb re-centers under the cursor, then drags from there.
|
// view so the thumb re-centers under the cursor, then drags from there.
|
||||||
static bool draggingH = false, draggingV = false;
|
static bool draggingH = false, draggingV = false;
|
||||||
@@ -1935,9 +2159,15 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
// Draw waveform scope view underneath the spectrogram
|
// Draw waveform scope view underneath the spectrogram
|
||||||
if (app.showScope && app.loaded && app.signal.samples != NULL) {
|
if (app.showScope && app.loaded && app.signal.samples != NULL) {
|
||||||
float totalArea = GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale;
|
// Sits below the time labels; the horizontal scrollbar is pinned
|
||||||
float scopeHeight = totalArea * (1.0f - app.dividerY) - 30 * renderScale;
|
// to the window's bottom edge, so the scope stops just above it
|
||||||
app.scopeView.y = viewBounds.y + viewBounds.height + 30;
|
// 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.x = viewBounds.x;
|
||||||
app.scopeView.width = viewBounds.width;
|
app.scopeView.width = viewBounds.width;
|
||||||
app.scopeView.height = (int)scopeHeight;
|
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).
|
// both happened to be up at once (shouldn't happen in practice).
|
||||||
DrawAutocropNotice();
|
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)
|
// About / help dialog (topmost)
|
||||||
DrawAboutDialog();
|
DrawAboutDialog();
|
||||||
|
|
||||||
@@ -2054,6 +2291,8 @@ int main(int argc, char* argv[])
|
|||||||
FreeAllCacheEntries(&app.fftCache);
|
FreeAllCacheEntries(&app.fftCache);
|
||||||
free(app.reassignBuffer);
|
free(app.reassignBuffer);
|
||||||
free(app.collisionFlags);
|
free(app.collisionFlags);
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
if (app.minimapTexture[i].id != 0) UnloadTexture(app.minimapTexture[i]);
|
||||||
FreeWaveEnvelope(&app.scopeView.envelope);
|
FreeWaveEnvelope(&app.scopeView.envelope);
|
||||||
FreeMlnl(&app.annotations);
|
FreeMlnl(&app.annotations);
|
||||||
FreeSignal(&app.signal);
|
FreeSignal(&app.signal);
|
||||||
|
|||||||
+88
-1
@@ -59,6 +59,35 @@ typedef struct {
|
|||||||
// How long the auto-crop toast stays up, in seconds of *focused* time.
|
// How long the auto-crop toast stays up, in seconds of *focused* time.
|
||||||
#define AUTOCROP_NOTICE_SECONDS 5.0f
|
#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
|
// How many overlapping annotation boxes the cursor-hit stack retains. Deeper
|
||||||
// piles than this are counted but not listed individually (the tooltip says
|
// piles than this are counted but not listed individually (the tooltip says
|
||||||
// "+N more"), which keeps a dense pile-up from covering the spectrogram.
|
// "+N more"), which keeps a dense pile-up from covering the spectrogram.
|
||||||
@@ -311,6 +340,35 @@ typedef struct {
|
|||||||
char autocropNoticeMsg[256];
|
char autocropNoticeMsg[256];
|
||||||
float autocropNoticeTimer; // seconds left; counts down only while focused
|
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
|
// Optional mLnL annotations parsed from the loaded WAV (empty if the file
|
||||||
// doesn't carry the chunk). The annotations overlay has two surfaces:
|
// doesn't carry the chunk). The annotations overlay has two surfaces:
|
||||||
// 1. A faint always-on draw on the spectrogram (alpha = opacityBase).
|
// 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
|
// Timeline lane state. The lane is rendered between the freq-range banner
|
||||||
// and the spectrogram pixels. Collapsed = single-row sparkline; expanded =
|
// and the spectrogram pixels. Collapsed = single-row sparkline; expanded =
|
||||||
// one row per kind currently enabled in the file.
|
// 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 hoveredTimelineEvent; // -1 = none; event index hovered in the lane
|
||||||
int selectedAnnotation; // -1 = none; persistent selection from a lane click
|
int selectedAnnotation; // -1 = none; persistent selection from a lane click
|
||||||
} SpectrogramApp;
|
} SpectrogramApp;
|
||||||
@@ -472,6 +531,34 @@ typedef struct {
|
|||||||
// Returns the keymap table and its entry count (defined in spectrogram.c).
|
// Returns the keymap table and its entry count (defined in spectrogram.c).
|
||||||
const KeyBinding* GetKeymap(int* count);
|
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)
|
// Small math helpers (header-inline so every module can use them)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -9,8 +9,18 @@ void ScanDirectory(const char* path);
|
|||||||
void FreeBrowserFiles(void);
|
void FreeBrowserFiles(void);
|
||||||
void DrawFileBrowser(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 ---
|
// --- Sidebar ---
|
||||||
void DrawSidebar(void);
|
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 ---
|
// --- PNG export ---
|
||||||
void ExportPNG(const SpectrogramApp* spa, const char* dirPath);
|
void ExportPNG(const SpectrogramApp* spa, const char* dirPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user