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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
This commit is contained in:
2026-08-12 15:28:44 -07:00
parent 82294844dd
commit cce96659b1
7 changed files with 1499 additions and 401 deletions
+45 -7
View File
@@ -126,17 +126,24 @@ or pressing **O** for the file browser. Try the bundled sample:
### Controls
**Navigating.** A left-drag pans by default and **Ctrl+drag** draws a selection
box; **Tab** (or the rail's pan/select icons) swaps which one is bare, and Ctrl
always means "the other one", so either mode does both without switching back.
Middle-drag always pans.
| Input | Action |
|-------|--------|
| **O** | Open file browser |
| **LMB drag** | Pan the view (**Ctrl+drag** to box-select) |
| **Tab** | Swap pan / select mode |
| **Middle-drag** / **Alt+drag** | Pan, regardless of mode |
| **Mouse wheel** | Zoom both axes (preserves aspect ratio) |
| **Shift+wheel** | Zoom the time axis only |
| **Ctrl+wheel** | Zoom the frequency axis only |
| **Alt+drag** / **middle-drag** | Pan the view |
| **LMB drag** | Select a time + frequency region |
| **Wheel on a scrollbar** | Pan that axis (**Shift** to zoom it) |
| **Space** | Play / stop the selected region |
| **Hover an annotation** | Tooltip with that frame's mLnL detail; lists **every** overlapping frame under the cursor |
| **N** / **Shift+N** | Jump to the next / previous collision |
| **O** | Open file browser |
| **P** | Show / hide the waveform scope |
| **M** | Marker / ruler tool |
| **S** | Spectrum slice (PSD) |
@@ -148,8 +155,30 @@ or pressing **O** for the file browser. Try the bundled sample:
| **F1** | About / help |
| **Esc** | Clear selection / close dialog |
Most controls are also available as buttons in the left sidebar (colormap, floor,
dynamic range, annotation opacity, grid, …).
### Layout
A menubar across the top holds one-shot actions (**File** — open, export
PNG/WAV; **View** — reset/zoom, hide the icon rail, fullscreen; **Annotations**
jump to next collision; **Help**). Everything that toggles lives on the rail
instead, so no control has two homes. Menu items are defined by naming a keyboard shortcut, so an item and
its key can never drift apart, and items grey out under exactly the conditions
that make the shortcut a no-op.
Down the left is a narrow **icon rail** — one column of square buttons, sized so
it costs the spectrogram as little width as possible. Hover any icon for a
tooltip. Left to right in function: play/stop and clear selection; pan/select
mode; marker, spectrum slice, scope, grid, minimap; FFT size and colour/level
popouts; annotations, collisions, and the timeline lane. The three settings
popouts open beside the rail rather than widening it. `View → Hide icon rail`
hands its width back to the spectrogram.
The **minimap** (top-right, toggled from the rail) is a thumbnail of the whole
capture with the current view drawn on it — click or drag anywhere on it to
scrub. Annotation density runs along its bottom edge and collisions along its
top. The corner handle switches between two sizes. It is rendered once into a
texture and only rebuilt when its *content* changes (new file, colormap,
overlays toggled); panning and zooming just move the rectangle drawn on top, so
navigation costs nothing.
### Inspecting overlapping transmissions
@@ -254,6 +283,14 @@ paths.
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
colour = amplitude.
- **Loading** — the STFT overview is computed in one blocking pass behind the
progress panel. It used to advance a fixed number of segments per frame, which
made loading frame-paced rather than compute-bound: the frame limiter, not the
FFT, set the speed, so a 478k-segment capture spent over a minute waiting
between frames. The tell was that backgrounding the window — which skips
presenting entirely — loaded the same file in seconds. Background work also
continues while the window is unfocused, so a long capture can be left to
finish behind another window.
- **Long files** — two things keep cost tied to what's on screen rather than to
total duration. The spectrogram image is built for the *visible* segment range
(capped at 8192 px wide), so a multi-hour capture renders at all — an
@@ -285,8 +322,9 @@ paths.
src/
spectrogram.c # entry point, main loop, CLI args, headless render
stft.c / fft.c # STFT + FFT
render.c # spectrogram, annotations, tooltips, scope
ui.c # sidebar, file browser, buttons
render.c # spectrogram, annotations, tooltips, minimap, scope
ui.c # menubar, icon rail + popouts, file browser
primitives.c # waveform scope + its min/max envelope summary
audio.c # WAV load (ffmpeg fallback), bandpass, playback, WAV export
mlnl.c / mlnl.h # mLnL annotation chunk parser
platform_*.c # per-OS shims (linux / win32 / web)