# Visual Rendering This document describes CubicSDR's OpenGL rendering system, canvas hierarchy, GL panel system, font rendering, color themes, and rendering flow. For the visual data processing pipeline (VisualProcessor, distributors, FFT processing), see [visual-data-pipeline.md](visual-data-pipeline.md). ## Overview The visual system is built on wxWidgets' OpenGL integration (`wxGLCanvas`/`wxGLContext`) and uses a **pull-based** architecture: worker threads produce FFT/scope data into queues, and UI canvases poll those queues in their `OnIdle()` or `OnPaint()` handlers. Worker threads never push data to the UI. The visual data processing pipeline — how raw IQ samples become display-ready spectrum/scope data, the VisualProcessor template, and the FFT/scope processing internals — is documented in [visual-data-pipeline.md](visual-data-pipeline.md). ## Canvas Class Hierarchy ``` wxGLCanvas | +-- InteractiveCanvas (base: mouse tracking, frequency mapping, key state) | +-- WaterfallCanvas (waterfall display, demod creation, drag operations) +-- SpectrumCanvas (FFT spectrum display, linked to WaterfallCanvas) +-- ScopeCanvas (oscilloscope/spectrum display for demod output) +-- MeterCanvas (signal level meter) +-- TuningCanvas (fine tuning control) +-- ModeSelectorCanvas (modem type selection buttons) +-- GainCanvas (per-gain-stage slider) +-- UITestCanvas (test/development canvas in src/ui/) ``` ### InteractiveCanvas (`src/visual/InteractiveCanvas.h`) Base class for all interactive GL canvases. Provides: - **Frequency mapping:** `getFrequencyAt(x)` converts a normalized x-position [0..1] to a frequency in Hz, given the current center frequency and bandwidth - **View state:** `setView()` / `disableView()` for zoomed sub-band views vs. full-band views - **Mouse tracking:** `MouseTracker` instance with position, button state, drag deltas - **Key state:** `shiftDown`, `altDown`, `ctrlDown` flags updated on key/mouse events - **Status bar:** `setStatusText()` writes to the AppFrame status bar ### WaterfallCanvas (`src/visual/WaterfallCanvas.h`) The primary display canvas. Handles: - **FFT data consumption:** Pops `SpectrumVisualData` from `visualDataQueue` at a rate-limited pace (`linesPerSecond`) - **Waterfall rendering:** Delegates to `WaterfallPanel` for texture-based scrolling waterfall - **Demodulator markers:** Draws all active demodulators via `PrimaryGLContext::DrawDemod()` - **Frequency selector:** Draws hover/active frequency markers via `DrawFreqSelector()` - **Drag operations:** - `WF_DRAG_FREQUENCY` — move demodulator frequency - `WF_DRAG_BANDWIDTH_LEFT` / `WF_DRAG_BANDWIDTH_RIGHT` — resize demodulator bandwidth - `WF_DRAG_RANGE` — create new demodulator by range selection - **Zoom:** Mouse wheel adjusts `mouseZoom`, which smoothly animates to the target zoom level - **Frequency nudge:** Outside view mode, arrow keys shift the center frequency by half a bandwidth (±10× with Shift); in view mode they latch a pan velocity (`freqMove` = ±1% or ±5% of bandwidth per frame) - **Visual gain:** Shift+Up/Down sets `scaleMove` (±1), which increments the spectrum scale factor by ±0.02 per frame (clamped to [0.25, 10]) while held **Drag state machine:** ``` WF_DRAG_NONE ──(mouse hover over demod)──> WF_DRAG_FREQUENCY / WF_DRAG_BANDWIDTH_* WF_DRAG_NONE ──(Alt held)──> WF_DRAG_RANGE WF_DRAG_* ──(mouse released)──> WF_DRAG_NONE ``` ### SpectrumCanvas (`src/visual/SpectrumCanvas.h`) Displays the FFT spectrum line plot. Linked to `WaterfallCanvas`: - Shares the same center frequency and bandwidth - Receives FFT data from its own `visualDataQueue` - Supports dB scale display and dB offset - Right-drag adjusts visual gain (vertical scale factor) - Uses `SpectrumPanel` for rendering ### ScopeCanvas (`src/visual/ScopeCanvas.h`) Displays demodulated audio as oscilloscope or spectrum: - Pops `ScopeRenderData` from `inputData` queue - Toggles between scope mode (waveform) and spectrum mode (FFT) - Supports PPM mode for frequency calibration display - Uses both `ScopePanel` and `SpectrumPanel` in a composite layout - `GLPanel` hierarchy: `parentPanel` → `scopePanel` + `spectrumPanel` (children); `bgPanel` is drawn independently with its own identity transform before `parentPanel` ## GLPanel System (`src/ui/GLPanel.h`) A lightweight retained-mode UI system for OpenGL rendering. Panels form a tree: each panel has position, size, fill, and optional children. The tree is traversed recursively each frame, with each panel computing its transform relative to its parent. ### Base Class Properties ``` GLPanel ├── pos[2], size[2], rot[3] // placement in parent coordinates ├── fill[2] (RGBA4f) // fill colors (solid or gradient endpoints) ├── fillType // rendering mode (see below) ├── borderColor, borderPx // border color (RGBA4f) and per-edge border widths in pixels ├── marginPx // margin in pixels ├── coord // coordinate system (see below) ├── visible, contentsVisible // visibility flags ├── srcBlend, dstBlend // custom blend modes (GLuint) ├── children (vector) // child panels ├── transform, transformInverse // computed global matrices ├── localTransform // pos/size/rot composed ├── vmin, vmax // computed screen-space extents ├── umin, umax, ucenter // computed unit-space extents ├── view[2] // viewport dimensions ├── min, mid, max // coordinate system extents (-1/0/1 or 0/0.5/1) ├── pdim, pvec // pixel dimensions and pixel vector └── (private) glPoints, glColors // pre-computed vertex/color arrays for fill rendering ``` **Derived panels:** - `GLTextPanel` — text rendering with alignment (left/right/center, top/bottom); `useNativeFont` bypasses scale factor when true - `GLTestPanel` — debug/test rendering **Panel classes:** | Panel | File | Purpose | |-------|------|---------| | `WaterfallPanel` | `src/panel/WaterfallPanel.h` | Scrolling waterfall texture rendering | | `SpectrumPanel` | `src/panel/SpectrumPanel.h` | FFT line plot rendering | | `ScopePanel` | `src/panel/ScopePanel.h` | Oscilloscope waveform rendering | | `MeterPanel` | `src/panel/MeterPanel.h` | Signal level meter rendering | ### Fill Types | Enum | Vertices | Description | |------|----------|-------------| | `GLPANEL_FILL_NONE` | 8 (allocated, not drawn) | Transparent — arrays allocated but `draw()` skips rendering | | `GLPANEL_FILL_SOLID` | 4 (1 quad) | Single color (`fill[0]`) | | `GLPANEL_FILL_GRAD_X` | 4 (1 quad) | Horizontal gradient: `fill[0]` (left) → `fill[1]` (right) | | `GLPANEL_FILL_GRAD_Y` | 4 (1 quad) | Vertical gradient: `fill[0]` (bottom) → `fill[1]` (top) | | `GLPANEL_FILL_GRAD_BAR_X` | 8 (2 quads) | Symmetric horizontal bar: gradient from edges to center | | `GLPANEL_FILL_GRAD_BAR_Y` | 8 (2 quads) | Symmetric vertical bar: gradient from edges to center | The bar fill types create a symmetric gradient: `fill[0]` at both edges, `fill[1]` at the center line. This is used for tuning bar indicators where the center value is the strongest. ### Coordinate Systems The `GLPanelCoordinateSystem` enum controls how the [-1,1] unit square maps to screen space. Each system applies a different matrix to transform content coordinates before drawing: | Enum | Mapping | Y Direction | Use Case | |------|---------|-------------|----------| | `GLPANEL_Y_DOWN_ZERO_ONE` | [0,1] → [-1,1] | Down | Waterfall — origin at top-left | | `GLPANEL_Y_UP_ZERO_ONE` | [0,1] → [-1,1] | Up | Most panels — origin at bottom-left | | `GLPANEL_Y_UP` | [-1,1] passthrough | Up | Raw unit coordinates | | `GLPANEL_Y_DOWN` | [-1,1] flip Y | Down | Raw coordinates with Y flipped | The coordinate matrix is applied after the panel's transform: `finalMatrix = transform * coordMatrix`. Content drawn in `drawPanelContents()` uses this coordinate space. ### Transform Pipeline: `calcTransform()` + `draw()` The render pipeline executes in two phases per panel: **Phase 1 — `calcTransform(parentTransform)`** (called by parent before drawing children): 1. Compose local transform: `localTransform = translate(pos) * scale(size)` (optionally `* rotate(rot)`) 2. Compute global transform: `transform = parentTransform * localTransform` 3. Read viewport: `glGetIntegerv(GL_VIEWPORT, vp)` 4. Transform coordinate-system corners through `transform` to get screen-space `vmin`/`vmax` (corners are `(min,min)` and `(max,max)` where min/max depend on the coordinate system: -1/+1 or 0/+1) 5. Compute pixel extents: `pdim = vec2((vmax.x - vmin.x) * 0.5 * vp_width, ...)` 6. Apply margin: shrink transform by `scale(1 - marginPx*2*pvec.x/size[0], 1 - marginPx*2*pvec.y/size[1], 1)` (pixel-vector-scaled margin divided by the panel size) 7. Compute `transformInverse = mat4::inverse(transform)` for hit-testing **Phase 2 — `draw()`** (called recursively): 1. Load modelview matrix: `glLoadMatrixf(transform.to_ptr())` 2. If `visible` and fill type is not NONE: - Enable `GL_BLEND` with the panel's blend mode - Draw pre-computed vertex/color arrays via `glDrawArrays(GL_QUADS, ...)` (arrays built earlier when fill properties changed via `genArrays()` in `setFill()`/`setFillColor()`/`setCoordinateSystem()`) - If any border is non-zero: enable `GL_LINE_SMOOTH`, draw border edges with `glBegin(GL_LINES)` 3. If `contentsVisible`: - Build coordinate matrix from the panel's coordinate system - `glLoadMatrixf((transform * coordMatrix).to_ptr())` - Call `drawPanelContents()` (virtual — overridden by subclasses) 4. Base `drawPanelContents()` calls `drawChildren()`, which iterates `children` and calls `calcTransform(this->transform)` + `draw()` on each child **Composition pattern**: The root panel's `draw()` is called once. It renders its own fill, then its `drawPanelContents()` triggers `drawChildren()`. Each child computes its transform relative to the parent and draws itself, creating a recursive tree traversal. ### Hit-Testing `GLPanel::hitTest(vec2 screenPos, vec2 &localResult)` transforms a screen-space point into the panel's local [-1,1] coordinate system using the precomputed `transformInverse`. If the transformed point falls within the unit square, the hit succeeds and the caller receives the local coordinates. Note: GLPanel hit-testing is used by `MeterPanel` (in `isMeterHit()` and `getMeterHitValue()`) for click-to-set-level interaction within `GainCanvas`. Other canvases (WaterfallCanvas, SpectrumCanvas, MeterCanvas, etc.) use `MouseTracker` with their own coordinate mapping instead. ## PrimaryGLContext (`src/visual/PrimaryGLContext.h`) Shared OpenGL context providing drawing primitives. All canvases share this context via `wxGLContext` sharing. Uses legacy OpenGL fixed-function pipeline (no shaders). ### Drawing Methods | Method | Purpose | |--------|---------| | `BeginDraw(r, g, b)` | Clear color+depth buffers, load identity modelview | | `EndDraw()` | Finalize frame — currently empty (no-op) | | `DrawDemod(demod, color, center_freq=-1, srate=0)` | Draw demodulator bandwidth indicator with label | | `DrawDemodInfo(demod, color, center_freq=-1, srate=0, centerline=false)` | Draw demodulator info label (frequency, type, bandwidth) | | `DrawFreqSelector(uxPos, color, w=0, center_freq=-1, srate=0)` | Draw frequency selection marker (center line + BW edges) | | `DrawRangeSelector(uxPos1, uxPos2, color)` | Draw range selection overlay (two vertical boundary lines) | | `DrawFreqBwInfo(freq, bw, color, center_freq=-1, srate=0, stack=false, centerline=false)` | Draw frequency and bandwidth text with shadow | ### GL State Management **`BeginDraw(r, g, b)`** (called at frame start): ``` glClearColor(r, g, b, 1) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glMatrixMode(GL_MODELVIEW) glLoadIdentity() ``` **Drawing primitives use immediate-mode OpenGL:** - `glEnable(GL_BLEND)` / `glBlendFunc()` — additive blending for overlays - `glDisable(GL_TEXTURE_2D)` before line drawing - `glBegin(GL_LINES)` / `glVertex3f()` — immediate mode for demod lines - `glBegin(GL_QUADS)` / `glVertex3f()` — immediate mode for filled regions - `glColor4f()` / `glColor3f()` — immediate mode color setting - `glLineWidth()` — variable width for range-selector emphasis (`DrawRangeSelector`) **Blend modes used:** | Mode | Components | Use | |------|------------|-----| | Additive | `GL_SRC_ALPHA, GL_ONE` | Demod bands, frequency selectors | | Alpha | `GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA` | Labels, backgrounds | | Opaque | `GL_ONE, GL_ZERO` | Shadow text in USB/LSB branches only (default branch shadow uses additive) | ### Buffer Strategy **No persistent GPU buffers** for drawing primitives. All PrimaryGLContext drawing is immediate-mode (`glBegin/glEnd`) for lines and quads. The only persistent GPU resources in the visual system are: - **Font atlas textures** — one per font size, uploaded once in `loadFontOnce()` - **Waterfall textures** — two double-buffered textures per waterfall panel, updated in `WaterfallPanel::update()` ### ScopeContext Extensions `ScopeContext` (subclass of `PrimaryGLContext`) adds scope-specific drawing: - `DrawBegin(clear)` — clears with scope background color, disables texture - `DrawTunerTitles(ppmMode)` — draws "Frequency", "Bandwidth", "Center Frequency" labels - `DrawDeviceName(name)` — draws device name in top-right - `DrawDivider()` — single white vertical line at center - `DrawEnd()` — finalize scope frame (currently empty, like `EndDraw()`) ### MeterContext (`src/visual/MeterContext.h`) Context for the signal level meter canvas. Minimal extension — no font rendering, purely filled geometry. - `DrawBegin()` — disables `GL_CULL_FACE` and `GL_DEPTH_TEST`, clears with theme background (`generalBackground`), loads identity modelview, disables texturing - `Draw(r, g, b, a, level)` — draws a vertical meter bar split at x=0 into left/right halves; each half uses a horizontal gradient (dim at outer edges, full brightness at center x=0). Additive blending (`GL_ONE, GL_ONE`). The `level` parameter (0.0–1.0) controls fill height. Note: the two halves are not symmetric — the left half dims only RGB at the outer edge, while the right half dims both RGB and alpha. - `DrawEnd()` — no-op ### TuningContext (`src/visual/TuningContext.h`) Context for the fine-tuning bar canvas. Renders per-digit frequency display with adaptive font sizing. - `DrawBegin()` — clears with theme background, disables texturing - `Draw(r, g, b, a, p1, p2)` — draws a horizontal tuning bar between positions `p1`–`p2` (normalized 0–1), split at y=0 with additive blending gradient (dim at edges, full at center) - `DrawTuner(freq, count, displayPos, displayWidth)` — renders a frequency value as individual digits, each in its own column with vertical grid lines at 25% alpha. Font size adapts to viewport via two sequential paths: width-based first (`<300px → 18px`, `<500px → 24px`, else → 32px), then height-based overrides (`<18px → 12px`, `<24px → 16px`, `<28px → 18px`; height ≥ 28px leaves the width-selected size unchanged) - `static DrawTunerDigitBox(index, count, displayPos, displayWidth, color)` — draws a red highlight box around a single digit position via `GL_LINE_STRIP` (note: `color` parameter is currently ignored; highlight is always red) - `GetTunerDigitIndex(mPos, count, displayPos, displayWidth)` — hit-test utility converting mouse position to digit index - `DrawTunerBarIndexed(start, end, count, displayPos, displayWidth, color, alpha, top, bottom)` — draws a colored bar for a range of digit indices on the top/bottom half with additive blending (note: `alpha` parameter is currently ignored; hardcoded to 0.6) - `DrawDemodFreqBw(freq, bw, center)` — composite method drawing three tuner displays side-by-side: demod frequency, bandwidth, and center frequency - `DrawEnd()` — no-op ### ModeSelectorContext (`src/visual/ModeSelectorContext.h`) Context for the modem type selection button canvas. Unique dual-drawing mode for active/inactive states. - `DrawBegin()` — clears with theme background, disables texturing - `DrawSelector(label, c, cMax, on, r, g, b, a, px, py)` — draws a single mode button. When `on=true`, draws a filled quad (active); when `on=false`, draws only an outline (`GL_LINE_LOOP`). Semi-transparent modes enable alpha blending. Text is black when active, otherwise drawn in the specified color. Buttons are distributed evenly across full height based on `cMax` - `DrawEnd()` — no-op ### UITestContext (`src/ui/UITestContext.h`) Context for the UI test/development canvas. The only context that uses the `GLPanel` widget hierarchy rather than raw GL calls. - Constructor builds test panel members: root `testPanel` (solid blue, 10px margin), `testChildPanel` (gradient bar, Y-down coords), `testChildPanel2` (gradient, red border, with `testText1` child showing "Testing 123.."), `testChildPanel3` (gradient, red border), and `testMeter` (meter widget). Currently only `testMeter` is parented to `testPanel`; the three child panels are instantiated but not added to the rendered tree (commented out) - `DrawBegin()` — disables cull face/depth test, clears with theme background, loads identity modelview - `Draw()` — computes root panel transform via identity matrix, then calls `testPanel.draw()` which recursively renders the entire GLPanel tree - `DrawEnd()` — no-op ### Hover Alpha A `hoverAlpha` float controls the transparency of hover indicators, animated smoothly over time for visual feedback on demodulator hover states. ## GLFont System (`src/util/GLFont.h`) Bitmap font rendering using BMFont format. Fonts are stored in `font/` as PNG + definition files. 11 bitmap font sizes (12-96px) are loaded on demand via `loadFontOnce()` (lazy initialization). Font file search order: 1. `RES_FOLDER/fonts/*` 2. `[exe_path]/RES_FOLDER/fonts/*` 3. `[exe_path]/fonts/*` ### Font Sizes | Enum | Pixels | Use Case | |------|--------|----------| | `GLFONT_SIZE12` | 12px | Small labels | | `GLFONT_SIZE16` | 16px | Status text | | `GLFONT_SIZE18` | 18px | Medium labels | | `GLFONT_SIZE24` | 24px | Frequency display | | `GLFONT_SIZE27` | 27px | — | | `GLFONT_SIZE32` | 32px | Large labels | | `GLFONT_SIZE36` | 36px | — | | `GLFONT_SIZE48` | 48px | Header text | | `GLFONT_SIZE64` | 64px | — | | `GLFONT_SIZE72` | 72px | — | | `GLFONT_SIZE96` | 96px | — | ### Font Scaling `GLFont::GLFontScale` provides user-configurable scaling: - `GLFONT_SCALE_NORMAL` — 1.0x - `GLFONT_SCALE_MEDIUM` — 1.5x - `GLFONT_SCALE_LARGE` — 2.0x Changing the scale factor calls `clearAllCaches()` to flush all string caches, since cached vertex data is baked at a specific scale. ### Drawer Proxy `GLFont::Drawer` is a lightweight proxy that selects the appropriate font size and scale factor: ```cpp GLFont::Drawer drawer = GLFont::getFont(24, scaleFactor); drawer.drawString("Hello", x, y, GLFONT_ALIGN_LEFT, GLFONT_ALIGN_TOP); ``` **Font selection algorithm** (`getFont()`): 1. Compute `targetSize = round(requestedSize * scaleFactor)` 2. Scan the `fonts[]` array to find the largest font where `pixHeight <= targetSize` 3. Compute `renderingFontScaleFactor = (double) targetSize / rawPixHeight` 4. The Drawer stores the selected font index and scale factor; `drawString()` applies the scale to the final rendering This means requesting size 24 with scale 1.5x selects the 36px font (if available) or the nearest smaller font scaled up. ### String Caching Each font maintains a `stringCache` map (`wstring → GLFontStringCache`) for pre-computed vertex/UV data. The cache key encodes viewport dimensions, pixel height, and the string text. **Cache entry structure:** - `drawlen` — number of renderable characters - `vpx`, `vpy` — viewport dimensions at cache time (invalidates on resize) - `pxHeight` — pixel height at cache time - `msgWidth` — precomputed string width - `gc` — atomic age counter for eviction - `gl_vertices` — pre-transformed vertex positions - `gl_uv` — texture coordinates **Cache hit path:** `drawCacheString()` renders all characters in a single `glDrawArrays(GL_QUADS, 0, 4 * drawlen)` call. No per-character transforms needed — vertices are pre-baked at cache creation. **Cache miss path:** `cacheString()` pre-transforms all character vertices through the final scale+translate matrix, stores them, then draws. **Cache eviction policy:** ``` Every ~50 cacheable draw calls (GC_DRAW_COUNT_PERIOD, fires when gcCounter > 50): │ ▼ doCacheGC() — single pass through stringCache map: │ ├── For each entry: decrement gc counter │ └── Remove first entry where gc < -GC_DRAW_COUNT_LIMIT (-10) (not drawn for 10+ GC cycles = 500+ draw calls) ``` Eviction is **amortized** — at most one entry removed per GC cycle, preventing frame spikes. On cache hit, `gc` resets to 0. This LRU-like policy keeps frequently used strings (demod labels, frequency readouts) cached while slowly evicting stale entries (old demod labels, transient status text). ### Thread Safety | Mechanism | Protects | Type | |-----------|----------|------| | `cache_busy` | String cache map access | SpinMutex (atomic flag spinlock) | | `currentScale` | Scale factor reads | std::atomic | | Font data (characters, textures) | Read-only after load | Inherently thread-safe | The `Drawer` object is stack-local (not shared between threads), so creating a Drawer per-frame is safe. The SpinMutex ensures only one thread can modify the cache map at a time, while concurrent reads of the font atlas texture are safe since it's immutable after upload. ## ColorTheme System (`src/visual/ColorTheme.h`) ### Theme Structure `ColorTheme` defines all colors used by the visual system: | Property | Used By | |----------|---------| | `waterfallGradient` | Waterfall color mapping (256-entry LUT) | | `waterfallHighlight` | Active demodulator marker | | `waterfallNew` | New demodulator being created | | `waterfallHover` | Hovered demodulator / frequency selector | | `waterfallDestroy` | Demodulator being deleted | | `wfHighlight` | Waterfall highlight overlay (declared but currently unused) | | `fftLine` | Spectrum line color | | `fftHighlight` | Highlighted spectrum point | | `scopeLine` | Oscilloscope trace color | | `tuningBarLight` / `tuningBarDark` | Tuning bar gradient | | `tuningBarUp` / `tuningBarDown` | Fine tuning direction indicators | | `meterLevel` / `meterValue` | Signal meter colors | | `text` | General text color | | `freqLine` | Frequency grid lines | | `button` / `buttonHighlight` | UI button colors | | `scopeBackground` | Scope canvas background | | `fftBackground` | Spectrum canvas background | | `generalBackground` | General background color | | `name` | Theme display name (string) | ### Available Themes | ID | Name | Class | Description | |----|------|-------|-------------| | 0 | Default | `DefaultColorTheme` | Google Turbo colormap (rainbow) | | 1 | DefaultJet | `DefaultColorThemeJet` | Original CubicSDR jet colormap | | 2 | Black & White | `BlackAndWhiteColorTheme` | Grayscale waterfall | | 3 | Sharp | `SharpColorTheme` | High-contrast blue-white-yellow-red | | 4 | Rad | `RadColorTheme` | Blue-green-orange-red-white | | 5 | Touch | `TouchColorTheme` | Dark purple/cyan/green/yellow/red | | 6 | HD | `HDColorTheme` | Blue-green-red-yellow-white | | 7 | Radar | `RadarColorTheme` | Green monochrome radar style | ### ThemeMgr Global singleton `ThemeMgr::mgr` manages theme selection: - `setTheme(themeId)` sets `currentTheme` pointer - `getTheme()` returns current `themeId` - `themes` map holds all `ColorTheme*` instances - Current theme is persisted in `AppConfig` ## Rendering Flow ### Per-Frame Rendering (WaterfallCanvas) All canvases follow the same rendering pattern in `OnPaint()`: ``` 1. glContext->SetCurrent(*this) // bind GL context 2. initGLExtensions() // WGL swap interval (Windows) 3. glViewport(0, 0, ClientSize.x, ClientSize.y) 4. glContext->BeginDraw(r, g, b) // clear buffers, load identity 5. panelTree.calcTransform(identity) // recursive transform computation 6. panelTree.draw() // recursive panel rendering 7. glContext->DrawDemod(...) // demodulator overlay markers 8. glContext->DrawFreqSelector(...) // frequency selection indicators 9. glContext->DrawRangeSelector(...) // range selection overlay (if active) 10. glContext->EndDraw() // finalize (empty) 11. SwapBuffers() // present frame ``` **Key detail:** The panel tree is drawn first (step 5-6), then overlays are drawn on top (steps 7-9) using `PrimaryGLContext` immediate-mode drawing. This two-pass approach ensures overlays always render above the waterfall/spectrum content. Note: `DrawFreqBwInfo` is called by `SpectrumCanvas` (not `WaterfallCanvas`) to show frequency/bandwidth readouts at the cursor position. ### OnIdle Processing Each canvas registers its own `EVT_IDLE` handler. wxWidgets fires `OnIdle` when the event loop is empty, giving canvases a chance to poll for new data: | Canvas | OnIdle Behavior | |--------|----------------| | `WaterfallCanvas` | `processInputQueue()` + `Refresh()` | | `SpectrumCanvas` | `Refresh()` + `event.RequestMore()` | | `ScopeCanvas` | `Refresh()` + `event.RequestMore()` | `WaterfallCanvas` is the only canvas that polls its input queue in `OnIdle` via `processInputQueue()`. `SpectrumCanvas` and `ScopeCanvas` process their input queues inside `OnPaint()` instead (via `try_pop()` at the start of the paint handler). All canvases use non-blocking `try_pop()` to avoid stalling the UI thread. `Refresh()` is always called to drive animations (zoom interpolation, hover alpha decay, frequency nudge velocity) even when no new data is available. ### WaterfallCanvas Paint Flow (Detailed) ``` OnPaint(): ├── Apply zoom interpolation: mouseZoom += (1.0 - mouseZoom) * 0.2 ├── Apply visual gain step: factor += scaleMove * 0.02 (clamped to [0.25, 10]) ├── Apply frequency nudge velocity: centerFreq += bandwidth * freqMove * 0.01 │ ├── GL setup (viewport, clear) │ ├── WaterfallPanel.draw() // texture-based scrolling waterfall │ ├── Upload new FFT rows as texture strips │ └── Draw with texture coordinate scrolling │ ├── Draw demodulator markers // one DrawDemod() per active demod │ └── Color-coded: highlight (active), hover, new, destroy │ ├── Draw frequency selector // hover/active frequency marker │ └── SwapBuffers() ``` ### ScopeCanvas Paint Flow (Detailed) ``` OnPaint(): ├── Pop ScopeRenderData from inputData queue │ ├── GL setup (viewport, clear) │ ├── bgPanel.draw() // independent identity transform, scope background │ ├── Compute panel positions (spring animation) │ ├── ctr += (ctrTarget - ctr) * 0.2 │ └── Apply 3D perspective: lookat(0, 0, -1.205) + rotation │ ├── Set panel visibility based on scopeVisible()/spectrumVisible() │ ├── scopePanel.contentsVisible = scopeVisible() │ └── spectrumPanel.contentsVisible = spectrumVisible() │ ├── parentPanel.draw() // recursive: scopePanel + spectrumPanel │ └── Uses ScopeVisualProcessor output data │ ├── ScopeContext overlay drawing │ ├── DrawDeviceName() │ └── DrawTunerTitles(ppmMode) │ └── SwapBuffers() ``` ### Data Flow Timing The pull-based architecture creates a producer-consumer pipeline with these timing characteristics: ``` SDRThread (producer) → SDRPostThread (channelizer/passthrough) │ ├── IQ data at sample rate (e.g., 2.4 Msps → 60 packets/sec at 40,000 samples/packet) │ ▼ SpectrumVisualProcessor (worker thread) │ ├── Processes at pace set by upstream FFTDataDistributor (~30-60/sec) ├── Applies smoothing (double EMA) to reduce flicker │ ▼ WaterfallCanvas (consumer, UI thread) │ ├── OnIdle() polls queue with try_pop() ├── Rate-limits waterfall line insertion (linesPerSecond, default 30) ├── Triggers Refresh() → OnPaint() → SwapBuffers() │ ▼ Display (~30-60 FPS depending on system) ``` For details on the visual data processing pipeline internals (VisualProcessor, distributors, FFT processing, ScopeVisualProcessor processing logic), see [visual-data-pipeline.md](visual-data-pipeline.md). ## Mouse Interaction ### MouseTracker (`src/util/MouseTracker.h`) All canvases share a `MouseTracker` instance that normalizes mouse coordinates and tracks drag state: - **Coordinates:** `mouseX`/`mouseY` normalized to [0,1] with Y flipped (`1.0 - clientY/height`) - **Deltas:** `deltaMouseX`/`deltaMouseY` computed each frame - **Origin:** `originMouseX`/`originMouseY` recorded on mouse-down for drag distance - **Button state:** `isMouseDown`, `isMouseRightDown`, `isMouseInView` - **Drag locks:** `vertDragLock`/`horizDragLock` — on Windows, physically warp cursor back to lock axis via `WarpPointer()`; on macOS/Linux, drag locks are not enforced visually ### Event Propagation All canvas event handlers follow the same pattern: 1. Call `InteractiveCanvas::OnMouseMoved(event)` — updates `mouseTracker` coordinates and modifier key flags (`shiftDown`, `altDown`, `ctrlDown`) 2. Perform canvas-specific logic Modifier keys are tracked as member booleans, updated on every mouse/key event. ### WaterfallCanvas Drag State Machine ``` enum DragState { WF_DRAG_NONE, WF_DRAG_BANDWIDTH_LEFT, WF_DRAG_BANDWIDTH_RIGHT, WF_DRAG_FREQUENCY, WF_DRAG_RANGE }; ``` **Hover state resolution** (`updateHoverState()`): ``` altDown? ──yes──> WF_DRAG_RANGE │no ▼ Cursor over demodulator (within initial 15 kHz filter, then dynamic buffer of `halfBw + 10kHz * (currentBw / globalBw)`)? ──no──> WF_DRAG_NONE │yes ▼ !shiftDown? ──no──> WF_DRAG_NONE (shift held bypasses demod-hover entirely) │yes ▼ |freqDiff| > bandwidth/3? ──yes──> WF_DRAG_BANDWIDTH_LEFT or RIGHT │no (based on which side of center; ▼ LSB/USB mode exceptions for direction) WF_DRAG_FREQUENCY ``` **Mouse-down:** Captures `nextDragState` into `dragState`, records `dragOfs` (offset from cursor to demod center) and `dragBW` (initial bandwidth for BW resize). **Mouse-move while dragging:** | State | Behavior | |-------|----------| | `WF_DRAG_BANDWIDTH_LEFT` | Resize from left edge: `bwDiff = deltaMouseX * bandwidth * 2`, clamp to [`MIN_BANDWIDTH`, `CHANNELIZER_RATE_MAX`] Hz | | `WF_DRAG_BANDWIDTH_RIGHT` | Resize from right edge: same formula, opposite sign (direction exceptions for LSB/USB modes) | | `WF_DRAG_FREQUENCY` | Move demod center: compute target freq from cursor, apply frequency snap | | `WF_DRAG_RANGE` | Visual range selection — highlight region between origin and current cursor | | (right-drag) | Forward to `spectrumCanvas->updateScaleFactorFromYMove(deltaMouseY)` for visual gain | **Mouse-release:** | State | Action | |-------|--------| | Zero-drag (click) | Create new demodulator or move existing one to cursor frequency | | `WF_DRAG_RANGE` | Create demodulator spanning the selected frequency range | | All states | Reset `dragState = WF_DRAG_NONE` | **Frequency snap:** When moving a demodulator, the frequency snaps to the nearest configurable snap value (powers of 10: 1, 10, 100, 1000, etc.). Default is 1 Hz (no snapping); the user sets this via the tuning canvas, and it is persisted in `AppConfig`. ### Frequency Zoom State Machine (Mouse Wheel) ``` Mouse wheel delta │ ▼ mouseZoom = 1.0 - (wheelRotation / linesPerAction) / 1000.0 │ ▼ (in OnPaint, per-frame interpolation) mouseZoom += (1.0 - mouseZoom) * 0.2 // smooth animation toward 1.0 │ ▼ |mouseZoom - 1.0| < 0.01? ──yes──> snap to 1.0, exit view mode if zoomed out past sample rate │no ▼ Apply zoom: bw = ceil(baseBandwidth * currentZoom), clamp to minBandwidth (default 30000 Hz) ``` The zoom keeps the frequency under the mouse cursor stable by adjusting the center frequency proportionally to the bandwidth change. ### Keyboard Navigation Arrow keys control frequency and display in the WaterfallCanvas: | Key | Condition | Action | |-----|-----------|--------| | LEFT | View mode | Pan center frequency by ±1.0% of bandwidth (±5.0% with Shift) | | LEFT | Not in view | Jump by half bandwidth left (10x bandwidth with Shift) | | RIGHT | View mode | Pan center frequency right | | RIGHT | Not in view | Jump by half bandwidth right (10x bandwidth with Shift) | | UP | No modifier | Zoom in (reduce bandwidth): sets `zoom = 0.95` while held (reset to 1.0 on key-up) | | DOWN | No modifier | Zoom out (increase bandwidth): sets `zoom = 1.05` while held (reset to 1.0 on key-up) | | UP | Shift held | Increase visual gain: `scaleMove = 1.0` | | DOWN | Shift held | Decrease visual gain: `scaleMove = -1.0` | Keyboard controls are **momentary**: on key-up, `scaleMove` resets to 0 and `zoom` resets to 1.0 (with UP/DOWN key-up re-arming `mouseZoom` to 0.95/1.05). `freqMove` is not hard-reset on key-up; it stops by velocity decay — `freqMove -= freqMove * 0.2` per frame, snapping to 0 at 0.01. ### SpectrumCanvas Interactions | Input | Action | |-------|--------| | Left-drag | Horizontal pan: `moveCenterFrequency(deltaX * bandwidth)` | | Right-drag | Visual gain: `updateScaleFactorFromYMove(deltaMouseY)`, clamped to [0.25, 10.0] (requires `scaleFactorEnabled`) | | Right-click (no vertical drag) | Animate scale factor back to 1.0 and reset the peak-hold accumulator on spectrum/demod processors (only triggers when `originDeltaMouseY == 0`; the on/off peak-hold toggle is done elsewhere) | | Mouse wheel | Forwarded to `WaterfallCanvas::OnMouseWheelMoved()` for coordinated zoom | | 'B' key | Toggle dB display mode | The spectrum canvas tracks cumulative bandwidth change (`bwChange`) and resets the scale factor when bandwidth changes exceed 400,000 Hz to prevent the display from becoming unreadable. ### ScopeCanvas Interactions | Input | Action | |-------|--------| | Left-drag | Panel slide with inertia: `dragAccel = 4.0 * deltaMouseX`, momentum-based snap | | Mouse wheel | No action (handler exists but is not registered in the event table) | Panel position animates with spring-like behavior: `ctr += (ctrTarget - ctr) * 0.2`. Panels use 3D perspective projection with `lookat(0, 0, -1.205, ...)` and rotation via `atan2(pos, 1.2)` for a card-flip effect. The two panels (scope + spectrum) are separated by `panelSpacing = 0.4f` (total interval = `panelWidth * 2.0 + panelSpacing`). ## Key Constants | Constant | Value | Purpose | |----------|-------|---------| | `DEFAULT_WATERFALL_LPS` | 30 | Default waterfall lines per second | | `MIN_BANDWIDTH` | 500 | Minimum demodulator bandwidth | | `CHANNELIZER_RATE_MAX` | 500000 | Maximum channelizer sample rate |