#pragma once

#include <algorithm>
#include <functional>
#include <QHash>
#include <QWidget>
#include <QPushButton>
#include <QVector>
#include <QPointer>
#include <QMap>
#include <QImage>
#include <QColor>
#include <QDateTime>
#include <QElapsedTimer>
#include <QVariant>
#include <QTimer>
#include <QLabel>

#include "DssRenderer.h"

class QVariantAnimation;
class QSoundEffect;

#ifdef AETHER_GPU_SPECTRUM
#include <QRhiWidget>
#include <rhi/qrhi.h>
#define SPECTRUM_BASE_CLASS QRhiWidget
#else
#define SPECTRUM_BASE_CLASS QWidget
#endif

namespace AetherSDR {

class SpectrumOverlayMenu;
class VfoWidget;
class PanadapterRenderScheduler;
struct PanadapterOverlayMessage;
class PanadapterMessageOverlay;

// Shared timeout for the dBm-range echo handshake between MainWindow's
// request-side tracker (wirePanadapter / PendingDbmRange) and SpectrumWidget's
// echo-side tracker (m_pendingDbmRangeEcho).  Both ends must expire on the
// same interval — if the request side stays patient longer than the echo
// side, the spectrum can drop the echo while MainWindow is still waiting
// for a match (and vice versa).  Keep them tied to this one constant.
inline constexpr qint64 kDbmRangeHandshakeTimeoutMs = 2000;

// Waterfall color scheme presets.
enum class WfColorScheme : int {
    Default = 0,   // black → dark blue → blue → cyan → green → yellow → red
    Grayscale,     // black → white
    BlueGreen,     // black → blue → teal → green → white
    Fire,          // black → red → orange → yellow → white
    Plasma,        // black → purple → magenta → orange → yellow
    Purple,        // SmartSDR "Add Purple": black→blue→green→yellow→red→purple→white
    Count          // sentinel — number of schemes
};

// Gradient stop used by waterfall color mapping.
struct WfGradientStop { float pos; int r, g, b; };

// Returns the gradient stops for a given color scheme.
const WfGradientStop* wfSchemeStops(WfColorScheme scheme, int& count);

// Returns the display name for a color scheme.
const char* wfSchemeName(WfColorScheme scheme);

// Spectrum render mode for the panadapter surface.
enum class SpectrumRenderMode : int {
    Mode2D = 0,    // FFT trace + scrolling waterfall (classic)
    Mode3D,        // 3DSS perspective stacked-trace surface
    Count          // sentinel
};

// Panadapter / spectrum display widget.
//
// Layout (top to bottom):
//   ~40% — spectrum line plot (current FFT frame, smoothed)
//   ~60% — waterfall (scrolling heat-map history)
//   20px — absolute frequency scale bar
//
// Overlays (drawn on top of spectrum + waterfall):
//   - Filter passband: semi-transparent band from filterLow to filterHigh Hz
//   - VFO marker: vertical orange line at the tuned VFO frequency
//
// Click anywhere in the spectrum/waterfall area to emit frequencyClicked().
// When AETHER_GPU_SPECTRUM is enabled, inherits QRhiWidget for GPU-accelerated
// waterfall rendering. Otherwise falls back to QPainter (QWidget).
class SpectrumWidget : public SPECTRUM_BASE_CLASS {
    Q_OBJECT
    // Expose the measured FFT noise floor (and the pan index that identifies
    // which spectrum this is) to the automation bridge so a driver can read
    // them generically via QObject::property() in dumpTree — without coupling
    // the core bridge to this GUI class. Used to prove post-TX floor recovery
    // (#3804) and any future floor/AGC-settle behaviour over the bridge (#3646).
    Q_PROPERTY(double noiseFloorDbm READ noiseFloorDbm)
    Q_PROPERTY(double displayFloorDbm READ displayFloorDbm)
    Q_PROPERTY(int panIndex READ panIndex)
    Q_PROPERTY(double centerMhz READ centerMhz)
    Q_PROPERTY(double bandwidthMhz READ bandwidthMhz)
    Q_PROPERTY(int centerLockSliceId READ centerLockSliceId)

public:
    explicit SpectrumWidget(QWidget* parent = nullptr);
    ~SpectrumWidget() override;

    // Per-pan settings persistence
    void setPanIndex(int idx) { m_panIndex = idx; }
    int panIndex() const { return m_panIndex; }
    QString settingsKey(const QString& base) const;
    void loadSettings();

    QSize sizeHint() const override { return {800, 300}; }
    int spectrumPixelHeight() const;

    // Set the frequency range covered by this panadapter.
    void setFrequencyRange(double centerMhz, double bandwidthMhz);
    // Same range update, but snaps instead of using the small pan-follow
    // animation. Center Lock uses this so the locked slice stays pinned.
    void setFrequencyRangeImmediate(double centerMhz, double bandwidthMhz);
    void clearDisplay();  // blank spectrum and waterfall on disconnect
    void resetGpuResources();  // tear down GPU pipelines for reparenting (#1240)
    void prepareForTopLevelChange(); // unregister QRhiWidget from the current backing-store QRhi
    void prepareForShutdown(); // tear down QRhi/native resources before QWidget backing store destruction
    QString rendererDescription() const;
    void setRenderScheduler(PanadapterRenderScheduler* scheduler);
    // macOS: whether the pan gets its own native NSView (historical default —
    // #714). AETHER_PAN_NO_NATIVE_WINDOW=1 opts out to validate the cheaper
    // composited path (no per-present raster flushSubWindow blend).
    static bool nativeWindowPreferred() {
        static const bool noNative =
            qEnvironmentVariableIntValue("AETHER_PAN_NO_NATIVE_WINDOW") == 1;
        return !noNative;
    }
    // panstats (automation bridge): per-widget frame-cost counters — what the
    // GUI thread spends preparing this panadapter's frames, split by section,
    // plus a cause breakdown of static-overlay rebuilds. `reset` zeroes the
    // counters after the read so successive reads measure disjoint intervals.
    Q_INVOKABLE QVariantMap panstatsSnapshot(bool reset);
    Q_INVOKABLE QVariantMap renderSchedulerStatsSnapshot(bool reset);
    // QRhiWidget surface geometry for `get rhi`: widget size, devicePixelRatio,
    // and (on GPU builds) the pinned fixedColorBufferSize so automation can
    // assert it stays even-aligned under a fractional QT_SCALE_FACTOR (#4091).
    Q_INVOKABLE QVariantMap automationRhiSnapshot() const;
    Q_INVOKABLE QVariantMap automationDssSnapshot() const;
    Q_INVOKABLE QVariantMap automationDssReset(bool kiwiStream);
    Q_INVOKABLE QVariantMap automationDssInjectRows(int count,
                                                    int firstPeakBin,
                                                    int stepBin,
                                                    bool kiwiStream,
                                                    double rowLowMhz = -1.0,
                                                    double rowHighMhz = -1.0);
    Q_INVOKABLE QVariantMap automationDssSetScrollback(bool live,
                                                       int offsetRows);
    Q_INVOKABLE QVariantMap traceDebugSnapshot();
    void setConnectionAnimationVisible(bool on, const QString& label = {});
    void setKiwiSdrConnectionOverlay(bool visible,
                                     const QString& detail = {},
                                     const QString& title = {});
    void upsertOverlayMessage(PanadapterOverlayMessage message);
    bool removeOverlayMessage(const QString& id);
    void clearOverlayMessages();
    Q_INVOKABLE bool automationUpsertOverlayMessage(const QString& id,
                                                    const QString& title,
                                                    const QString& detail,
                                                    int timeoutMs,
                                                    const QString& toneName);
    Q_INVOKABLE bool automationRemoveOverlayMessage(const QString& id);
    Q_INVOKABLE void automationClearOverlayMessages();
    Q_INVOKABLE QVariantList overlayMessageSnapshot() const;
    void showInterlockNotification(const QString& message,
                                   const QString& key = QString(),
                                   int durationMs = 5000);

    // Feed a new FFT frame. bins are scaled dBm values.
    void updateSpectrum(const QVector<float>& binsDbm);

    // Feed a single waterfall row from a VITA-49 waterfall tile.
    // lowFreqMhz/highFreqMhz describe the tile's frequency span.
    // When waterfall tile data is available, this is used instead of
    // the FFT-derived waterfall rows from updateSpectrum().
    void updateWaterfallRow(const QVector<float>& binsDbm,
                            double lowFreqMhz, double highFreqMhz,
                            quint32 timecode = 0);
    void setKiwiSdrWaterfallAvailable(bool available);
    void setKiwiSdrWaterfallActive(bool active);
    bool kiwiSdrWaterfallActive() const { return m_kiwiSdrWaterfallActive; }
    void setKiwiSdrDisplaySourceControlVisible(bool visible);
    void setKiwiSdrDisplaySourceKiwi(bool kiwi);
    bool kiwiSdrDisplaySourceKiwi() const { return m_kiwiSdrDisplaySourceKiwi; }
    void setKiwiSdrWaterfallProfile(const QString& profileId);
    void clearKiwiSdrWaterfallRows();
    void clearKiwiSdrWaterfallRowsForProfile(const QString& profileId);
    void setKiwiSdrWaterfallDisplayRange(float minDbm, float maxDbm,
                                         bool autoRange);
    void updateKiwiSdrWaterfallRow(const QVector<float>& binsDbm,
                                   double lowFreqMhz, double highFreqMhz,
                                   quint32 timecode = 0);

    // Update the dBm range used for the waterfall colour map and spectrum Y axis.
    void setDbmRange(float minDbm, float maxDbm);

    // Noise floor auto-adjust: position (1=top, 99=bottom), enable on/off.
    // The enable flag is shared for the pan; the position is stored separately
    // for Flex and Kiwi display sources so switching views restores each scale.
    void setNoiseFloorPosition(int pos);
    void setNoiseFloorEnable(bool on);
    void prepareForFftScaleChange();
    void suspendNoiseFloorAutoAdjustUntil(qint64 untilMs);
    void resumeNoiseFloorAutoAdjust();
    void reacquireNoiseFloorLock();

    // Two-pass trimmed-mean noise floor from live FFT bins (dBm), EMA-smoothed.
    // Pass 1 computes the overall mean; pass 2 averages only bins ≤ mean so
    // signal peaks exclude themselves, leaving the flat noise baseline.
    // Reflects the current band, antenna and preamp — no hardcoded dBm value.
    float noiseFloorDbm() const { return m_measuredNoiseFloorDbm; }

    // Noise floor of the *displayed* FFT trace — the smoothed green line the
    // user actually reads — measured off m_smoothed (the client-side EMA) rather
    // than the raw incoming frame that noiseFloorDbm() tracks. This is what moves
    // when the post-TX EMA is (or isn't) reset, so it is the quantity that proves
    // the #3804 recovery fix over the automation bridge. Sentinel -1000 = no trace.
    float displayFloorDbm() const {
        return m_smoothed.isEmpty() ? -1000.0f : estimateNoiseFloorDbm(m_smoothed);
    }

    // Flex squelch threshold overlay line. level is the radio squelch_level
    // (0-100), mapped to absolute dBm via the radio's fixed scale:
    // dBm = -160 + level. (Empirically verified on FLEX-8600 fw 4.1.5.)
    void setSquelchLine(bool visible, int level);
    // KiwiSDR SQL is a dB margin above Kiwi's median noise-floor estimate.
    // marginDb is the server margin, not the UI slider value.
    void setKiwiSdrSquelchLine(bool visible, int marginDb, bool floorRelative);
    void setKiwiSdrSquelchMeterDbm(float dbm, bool squelched);
    void clearKiwiSdrSquelchLine();

    // When enabled, measures the noise floor on every FFT frame using a
    // two-pass trimmed mean (pass 1: overall mean; pass 2: mean of bins
    // at or below pass-1 mean to exclude signal peaks).  An EMA (α=0.1)
    // smooths frame-to-frame variation.  Emits autoSquelchLevelSuggested()
    // with a squelch level just above the smoothed floor.
    void setAutoSquelchEnable(bool on);

    // Margin above the EMA-smoothed noise floor for auto-squelch suggestion
    // (5-20 dB, default 10).  User-tunable via Display > SQL Margin.
    void setAutoSqlMarginDb(int dB);

    // (getters for display settings are below with their members)

    // Set the VFO frequency (draws the orange VFO marker).
    void setVfoFrequency(double freqMhz);

    // Set the filter edges (Hz offsets from VFO frequency).
    void setVfoFilter(int lowHz, int highHz);

    // Getters for band settings capture.
    float spectrumFrac()  const { return m_spectrumFrac; }
    float refLevel()      const { return m_refLevel; }
    float dynamicRange()  const { return m_dynamicRange; }
    bool isDraggingDbmScale() const {
        return m_draggingDbm || m_draggingDbmRange || m_draggingDssFloor;
    }
    bool pendingAutoNoiseFloorDbmRange() const {
        return m_pendingDbmRangeEcho && m_pendingDbmRangeEchoFromAutoFloor;
    }
    bool noiseFloorAutoAdjustEnabled() const { return m_noiseFloorEnable; }
    double centerMhz()    const { return m_centerMhz; }
    double bandwidthMhz() const { return m_bandwidthMhz; }
    // Width of the frequency canvas, in logical pixels: the widget width minus
    // the right-edge dBm / waterfall-time strip that is painted on top of it.
    // The spectrum, waterfall, and every mhzToX/xToMhz mapping span this width
    // so the trace ends at the tape (not under it) and Pan-Follows-VFO margins
    // are symmetric in pixels, not just in frequency (#3482).
    int contentWidth() const;

    // Set the FFT/waterfall split ratio programmatically.
    void setSpectrumFrac(float f);

    // Get/set the click/scroll tuning step size in Hz (default 100).
    int stepSize() const { return m_stepHz; }
    void setStepSize(int hz) { m_stepHz = hz; }

    // Set panadapter bandwidth zoom limits (MHz). Called per-radio model.
    void setBandwidthLimits(double minMhz, double maxMhz) { m_minBwMhz = minMhz; m_maxBwMhz = maxMhz; }

    // Set the per-mode filter limits (Hz). Called when mode changes.
    void setFilterLimits(int minHz, int maxHz) { m_filterMinHz = minHz; m_filterMaxHz = maxHz; }

    // Set the current demod mode (for zoom centering behavior).
    void setMode(const QString& mode) { m_mode = mode; }

    // Access the floating overlay menu (for wiring signals).
    SpectrumOverlayMenu* overlayMenu() const { return m_overlayMenu; }

    // Access VFO info widgets (one per slice).
    VfoWidget* vfoWidget() const { return m_vfoWidget; }  // active slice (compat)
    VfoWidget* vfoWidget(int sliceId) const;
    VfoWidget* addVfoWidget(int sliceId);
    VfoWidget* takeVfoWidget(int sliceId);
    void       adoptVfoWidget(int sliceId, VfoWidget* widget);
    void       removeVfoWidget(int sliceId);
    void       setActiveVfoWidget(int sliceId);
    bool vfoFlagOnLeftForSlice(int sliceId, double freqMhz,
                               int panelWidth, bool previousOnLeft) const;
    // True if the slice has a split partner whose own VFO flag is rendered on
    // the opposite side via LockLeft / LockRight.  panFollowVfo() uses this
    // to extend the pan-follow trigger on both sides so neither flag clips
    // the pan edge (#2761).
    bool sliceHasSplitPartner(int sliceId) const;

    // WNB and RF gain state for on-screen indicators.
    bool wnbActive()   const { return m_wnbActive; }
    bool wnbUpdating() const { return m_wnbUpdating; }
    int  rfGainValue() const { return m_rfGainValue; }
    bool wideActive()  const { return m_wideActive; }
    void setWnbActive(bool on) { syncWnbState(on, 0, false); }
    void syncWnbState(bool on, int level, bool updating) {
        Q_UNUSED(level);
        if (m_wnbActive != on || m_wnbUpdating != updating) {
            m_wnbActive = on;
            m_wnbUpdating = updating;
            markOverlayDirty();
        }
    }
    void setRfGain(int gain) {
        if (m_rfGainValue != gain) {
            m_rfGainValue = gain;
            reacquireNoiseFloorLock();
        }
        markOverlayDirty();
    }
    void setWideActive(bool on) {
        if (m_wideActive != on) {
            m_wideActive = on;
            markOverlayDirty();
        }
    }

    // HF propagation forecast overlay (K-index, A-index, and Solar Flux Index).
    // Values of -1 mean not yet fetched; visible only when enabled.
    void setPropForecastVisible(bool on) { m_propForecastVisible = on; markOverlayDirty(); }
    void setPropForecast(double kIndex, int aIndex, int sfi) {
        m_propKIndex = kIndex;
        m_propAIndex = aIndex;
        m_propSfi = sfi;
        markOverlayDirty();
    }
    bool propForecastVisible() const { return m_propForecastVisible; }

    // MQTT device status overlay (#699)
    void setMqttDisplayValue(const QString& key, const QString& value) {
        m_mqttDisplayValues[key] = value; markOverlayDirty();
    }
    void clearMqttDisplay() { m_mqttDisplayValues.clear(); markOverlayDirty(); }

    // NB Waterfall Blanker (#277) — client-side impulse suppression
    void setWfBlankerEnabled(bool on);
    void setWfBlankerThreshold(float t);
    void setWfBlankerMode(int mode);  // 0=Fill, 1=Interpolate
    bool  wfBlankerEnabled()   const { return m_wfBlankerEnabled; }
    float wfBlankerThreshold() const { return m_wfBlankerThreshold; }
    int   wfBlankerMode()      const { return m_wfBlankerMode; }
    void setShowBandPlan(bool on) { m_bandPlanFontSize = on ? 6 : 0; update(); }
    void setBandPlanFontSize(int pt) { m_bandPlanFontSize = pt; update(); }
    void setBandPlanShowSpots(bool on) { m_bandPlanShowSpots = on; update(); }
    bool bandPlanShowSpots() const { return m_bandPlanShowSpots; }
    void setBandPlanManager(class BandPlanManager* mgr);
    void setSingleClickTune(bool on) { m_singleClickTune = on; }
    void setShowCursorFreq(bool on) { m_showCursorFreq = on; markOverlayDirty(); }
    bool showCursorFreq() const { return m_showCursorFreq; }
    void setShowFpsMeters(bool on);
    bool showFpsMeters() const { return m_showFpsMeters; }
    void setFpsMeterSyncStatsProvider(std::function<QString()> provider);
    void setShowTuneGuides(bool on);
    bool showTuneGuides() const { return m_showTuneGuides; }
    void setExtendedFrequencyLine(bool on);
    bool extendedFrequencyLine() const { return m_extendedFrequencyLine; }
    void setExtendedPassband(bool on);
    bool extendedPassband() const { return m_extendedPassband; }
    void setFloating(bool on) { m_isFloating = on; }
    void setBackgroundImage(const QString& path);
    QString backgroundImagePath() const { return m_bgImagePath; }
    void setBackgroundOpacity(int pct) { m_bgOpacity = qBound(0, pct, 100); markOverlayDirty(); }
    int backgroundOpacity() const { return m_bgOpacity; }
    void setBackgroundFillColor(const QColor& c);
    QColor backgroundFillColor() const { return m_bgFillColor; }
    bool showBandPlan() const { return m_bandPlanFontSize > 0; }
    int  bandPlanFontSize() const { return m_bandPlanFontSize; }

    // ── Display control setters ───────────────────────────────────────────
    // FFT processing controls are radio-owned. These setters update the local
    // view only; MainWindow sends explicit operator changes and live radio
    // status updates the same fields without creating a feedback loop.
    void setFftAverage(int frames);
    void setFftWeightedAvg(bool on);
    void setFftFps(int fps);
    void setFftFillAlpha(float a);
    void setFftFillColor(const QColor& c);
    void setFftHeatMap(bool on);
    void setShowGrid(bool on);
    void setFreqGridSpacing(int khz);
    void setFreqScaleFontPt(int pt);
    void setFftLineWidth(float w);
    float fftFillAlpha() const         { return m_fftFillAlpha; }
    QColor fftFillColor() const        { return m_fftFillColor; }
    bool fftHeatMap() const            { return m_fftHeatMap; }
    bool showGrid() const              { return m_showGrid; }
    int  freqGridSpacing() const       { return m_freqGridSpacingKhz; }
    int  freqScaleFontPt() const       { return m_freqScaleFontPt; }
    float fftLineWidth() const         { return m_fftLineWidth; }
    int   fftAverage() const           { return m_fftAverage; }
    int   fftFps() const               { return m_fftFps; }
    bool  fftWeightedAvg() const       { return m_fftWeightedAvg; }
    bool panDragActive() const { return m_draggingPan; }
    bool frequencyRangeGestureActive() const
    {
        return m_draggingBandwidth || m_frequencyRangeSettlePending;
    }
    bool waterfallViewUpdateDeferred() const
    {
        return m_draggingPan;
    }

    // Client-rendered waterfall controls persist locally except line_duration,
    // which is radio-owned and is updated from live status.
    void setWfColorGain(int gain);
    void setWfBlackLevel(int level);
    void setWfAutoBlack(bool on);
    // Auto-black offset (0-100, 50 = noise floor, <50 darker, >50 lighter).
    // Only consulted while m_wfAutoBlack is on; lets users bias the noise-
    // floor target without leaving auto-black.
    void setWfAutoBlackOffset(int level);
    // Radio-computed auto-black level from the latest waterfall tile (raw uint16
    // domain, radio-authoritative). 0 = not yet received → the client falls back
    // to its own noise-floor estimate.
    void setRadioAutoBlackLevel(quint32 rawLevel);
    // Auto-black source: false = client-side noise-floor estimate (default,
    // legacy look); true = the radio's per-tile auto-black level. Only consulted
    // while m_wfAutoBlack is on.
    void setWfAutoBlackRadioSide(bool radioSide);
    void setWfLineDuration(int ms);
    void setWfColorScheme(int scheme);
    // Spectrum render mode: 2D (FFT trace + waterfall) or 3D (3DSS stacked
    // perspective trace surface). Persisted per-panadapter.
    void setSpectrumRenderMode(int mode);
    int  spectrumRenderMode() const { return static_cast<int>(m_spectrumRenderMode); }
    // 3DSS floor depth: how far below the measured noise floor to surface (dB),
    // lifting the floor carpet into view. Stored separately for Flex and Kiwi
    // display sources so switching views restores each 3D trace position.
    void setDssFloorDepth(int dB);
    int  dssFloorDepth() const { return static_cast<int>(std::lround(-m_dssFloorOffsetDb)); }
    // 3DSS colour floor (0-100): how far down the strength range the colormap
    // reaches. Higher lifts colour toward the noise floor; lower keeps colour on
    // strong signals only (gamma-shapes the palette lookup). Persisted per-pan.
    void setDssGain(int pct);
    int  dssGain() const { return m_dssGain; }
    void resetWfTimeScale();
    int   wfColorGain() const          { return m_wfColorGain; }
    int   wfBlackLevel() const         { return m_wfBlackLevel; }
    bool  wfAutoBlack() const          { return m_wfAutoBlack; }
    int   wfAutoBlackOffset() const    { return m_wfAutoBlackOffset; }
    bool  wfAutoBlackRadioSide() const { return m_wfAutoBlackRadioSide; }
    int   wfLineDuration() const       { return m_wfLineDuration; }
    int   wfColorScheme() const        { return static_cast<int>(m_wfColorScheme); }
    int   noiseFloorPosition() const   { return m_noiseFloorPosition; }
    bool  noiseFloorEnabled() const    { return m_noiseFloorEnable; }

    // Set slice info for the off-screen VFO indicator (legacy single-slice).
    void setSliceInfo(int sliceId, bool isTxSlice);

    // ── Multi-slice overlay API ───────────────────────────────────────────
    struct SliceOverlay {
        int    sliceId{0};
        double freqMhz{0};
        int    filterLowHz{0};
        int    filterHighHz{0};
        bool   isTxSlice{false};
        bool   isActive{false};
        int    splitPartnerId{-1};  // slice ID of split partner, -1 if not in split
        bool   diversity{false};
        bool   diversityParent{false};
        bool   diversityChild{false};
        int    diversityIndex{-1};
        QString mode;               // "RTTY", "USB", etc.
        int    rttyMark{2125};      // RTTY mark audio offset (Hz)
        int    rttyShift{170};      // RTTY shift (Hz)
        bool   ritOn{false};
        int    ritFreq{0};          // Hz offset
        bool   xitOn{false};
        int    xitFreq{0};          // Hz offset
        // Per-slice VFO marker display preferences (#1526).
        // markerWidth: 0 = off (no center line / triangle, passband only),
        // 1 = 1 px, 3 = 3 px.
        int    markerWidth{1};
        bool   filterEdgesHidden{false};  // skip drawing filter-edge vertical lines
        bool   adaptiveEnabled{false};    // draw adaptive-filter edge markers (RFC #3878)
        bool   adaptiveActive{false};     // a confident auto fit is currently applied
        QString perClientLetter;   // radio-provided index_letter (Multi-Flex)
    };

    // Add or update a slice overlay (called per-slice on any state change).
    bool isDraggingFilter() const { return m_draggingFilter != FilterEdge::None; }
    void setSliceOverlay(int sliceId, double freq, int fLow, int fHigh,
                         bool tx, bool active, const QString& mode = {},
                         int rttyMark = 2125, int rttyShift = 170,
                         bool ritOn = false, int ritFreq = 0,
                         bool xitOn = false, int xitFreq = 0,
                         bool diversity = false,
                         bool diversityParent = false,
                         bool diversityChild = false,
                         int diversityIndex = -1);
    // Update just the frequency on an existing overlay (for optimistic scroll-to-tune)
    void setSliceOverlayFreq(int sliceId, double freqMhz);
    // Update the per-client letter on an existing overlay; safe to call
    // before/after setSliceOverlay.  Used by the Multi-Flex display mode
    // so the slice marker / passband colour can follow the radio's
    // index_letter assignment (#2606).
    void setSliceOverlayLetter(int sliceId, const QString& letter);
    // Update per-slice marker display style (#1526)
    void setSliceOverlayMarkerStyle(int sliceId, int markerWidth, bool filterEdgesHidden);
    // Toggle the adaptive-filter floor-level edge markers for a slice (RFC #3878)
    void setSliceOverlayAdaptive(int sliceId, bool enabled);
    // Status of the adaptive fit (green/red ball after the high-cut label)
    void setSliceOverlayAdaptiveActive(int sliceId, bool active);
    void setCenterLockSliceId(int sliceId);
    int centerLockSliceId() const { return m_centerLockSliceId; }
    // Remove a slice overlay.
    void removeSliceOverlay(int sliceId);

    // Mark two slices as a split pair (RX + TX). Pass -1 to clear.
    void setSplitPair(int rxSliceId, int txSliceId);

    // ── TNF overlay ─────────────────────────────────────────────────────
    struct TnfMarker {
        int    id;
        double freqMhz;
        int    widthHz;
        int    depthDb;
        bool   permanent;
    };
    void setTnfMarkers(const QVector<TnfMarker>& markers);
    void setTnfGlobalEnabled(bool on);

    struct SpotMarker {
        int    index;
        QString callsign;
        double freqMhz;
        QString color;       // #AARRGGBB or empty for default
        QString mode;
        QColor  dxccColor;   // DXCC-aware color from DxccColorProvider (#330)
        QString source;
        QString spotterCallsign;
        QString comment;
        qint64  timestampMs{0};
        // Protocol-supplied pill color (#AARRGGBB). Honored only when
        // Override Background is off — see drawSpotMarkers().
        QString backgroundColor;
    };
    void setSpotMarkers(const QVector<SpotMarker>& markers);

    struct SpotCluster {
        QRect rect;
        QVector<SpotMarker> spots;
    };

    struct SwrSweepPoint {
        double freqMhz{0.0};
        float swr{1.0f};
    };
    void setSwrSweepPoints(const QVector<SwrSweepPoint>& points,
                           bool running = false,
                           double currentFreqMhz = -1.0,
                           const QString& sourceLabel = {});
    void clearSwrSweepPoints();

    void setShowSpots(bool on) { m_showSpots = on; m_hoveredSpotKey.clear(); update(); }
    bool showSpots() const { return m_showSpots; }
    void setShowSHistory(bool on)    { m_showSHistory = on;    update(); }
    bool showSHistory() const         { return m_showSHistory; }
    void setShowSHistoryQrm(bool on) { m_showSHistoryQrm = on; update(); }
    bool showSHistoryQrm() const      { return m_showSHistoryQrm; }
    // Smart Spot Filtering: dim SSB/voice spots whose frequency is not within
    // ±1 kHz of a live S-History detection.  Once matched, a spot stays at full
    // opacity for 2 minutes after its last confirmation.  CW/digital spots are
    // always shown at full opacity regardless of this setting.
    void setSmartSpotFilter(bool on, qint64 enabledMs = 0) {
        if (on && !m_smartSpotFilter)
            m_smartSpotFilterEnabledMs = (enabledMs > 0) ? enabledMs
                                                         : QDateTime::currentMSecsSinceEpoch();
        m_smartSpotFilter = on;
        update();
    }
    bool smartSpotFilter() const     { return m_smartSpotFilter; }
    void setSmartSpotFilterOpacity(int pct) { m_smartSpotFilterOpacity = std::clamp(pct, 0, 100); update(); }
    void setSmartSpotFilterDelayS(int s)    { m_smartSpotFilterDelayS  = std::max(0, s); }
    // Match window between a DX-cluster spot and an S-History voice
    // detection (Hz, clamped to 100–5000).  ±this many Hz around each
    // S-History center counts as a match.  Tight = fewer false confirms
    // on crowded phone bands; loose = better tolerance for cluster
    // operators who spot the QRG they tuned through rather than the
    // exact carrier.  (#2609)
    void setSmartSpotFilterMatchHz(int hz)  { m_smartSpotFilterMatchHz = std::clamp(hz, 100, 5000); }
    // When on, click-to-tune on a SHistory/QRM marker rounds the target to
    // the nearest multiple of stepSize().  Compensates for the inherent
    // detector edge-bin imprecision (typically 100–300 Hz off carrier).
    void setSHistorySnapToStep(bool on) { m_sHistorySnapToStep = on; }
    bool sHistorySnapToStep() const     { return m_sHistorySnapToStep; }
    void setSHistoryMarkers(const QVector<SpotMarker>& markers);
    void setSpotFontSize(int px) { m_spotFontSize = px; markOverlayDirty(); }
    void setSpotMaxLevels(int n) { m_spotMaxLevels = n; markOverlayDirty(); }
    void setSpotStartPct(int pct) { m_spotStartPct = pct; markOverlayDirty(); }
    void setSpotOverrideColors(bool on) { m_spotOverrideColors = on; markOverlayDirty(); }
    void setSpotOverrideBg(bool on) { m_spotOverrideBg = on; markOverlayDirty(); }
    void setSpotShowLines(bool on) { m_spotShowLines = on; markOverlayDirty(); }
    bool spotShowLines() const { return m_spotShowLines; }
    void setSpotColor(const QColor& c) { m_spotColor = c; markOverlayDirty(); }
    void setSpotBgColor(const QColor& c) { m_spotBgColor = c; markOverlayDirty(); }
    void setSpotBgOpacity(int pct) { m_spotBgOpacity = pct; markOverlayDirty(); }
    void setTransmitting(bool tx);
    void setShowTxInWaterfall(bool on) { m_showTxInWaterfall = on; }
    void setHasTxSlice(bool has) { m_hasTxSlice = has; }
    void setTxWaterfallSlice(double freqMhz, int filterLowHz, int filterHighHz,
                             bool xitOn, int xitFreq);
    void clearTxWaterfallSlice();

signals:
    // Emitted when auto-squelch computes a new suggested level (0-100 radio units).
    // Connect to SliceModel::setSquelch and setSquelchLine to apply.
    void autoSquelchLevelSuggested(int level);

    // Emitted when user clicks on an inactive slice marker.
    void sliceClicked(int sliceId);
    // Emitted when the user requests an absolute jump in the panadapter area.
    void frequencyClicked(double mhz);
    // Emitted when the user makes an incremental tuning gesture such as
    // wheel tuning or VFO drag.
    void incrementalTuneRequested(double mhz);
    // Edge auto-pan step: pan the view to newCenterMhz AND tune the slice to
    // sliceFreqMhz in one shot, WITHOUT triggering pan-follow/reveal (which is
    // already accounted for by the explicit center).  Keeps the dragged slice
    // pinned under the cursor while the band scrolls.  (user-reported)
    void edgePanTuneRequested(double newCenterMhz, double sliceFreqMhz);
    // Emitted when a slice drag (in-window tune or edge auto-pan) starts (true)
    // and ends (false), so Pan Follow can stand down for the drag's duration and
    // recenter once on release. (user-reported)
    void sliceDragActiveChanged(bool active);
    void spotTriggered(int spotIndex);
    // Emitted when the user changes both center and bandwidth as one explicit
    // pan/zoom operation and the radio should apply them coherently. Splitting
    // those into separate commands was a known source of waterfall edge loss
    // and zoom drift during bandwidth drag / keyboard zoom.
    void frequencyRangeChangeRequested(double newCenterMhz, double newBandwidthMhz);
    void frequencyRangeChanged(double centerMhz, double bandwidthMhz);
    // Emitted when the user drags the frequency scale bar to change bandwidth.
    void bandwidthChangeRequested(double newBandwidthMhz);
    // Band/segment zoom: radio handles center/bandwidth (SmartSDR pcap: "band_zoom=1" / "segment_zoom=1")
    void bandZoomRequested();
    void segmentZoomRequested();
    // Emitted when the user drags the waterfall to pan the center frequency.
    void centerChangeRequested(double newCenterMhz);
    // Emitted when waterfall pan-dragging pauses or ends. Remote waterfall
    // providers use this to avoid resetting their stream on every drag step.
    void panDragSettled(double centerMhz, double bandwidthMhz);
    // Emitted when zoom/range interaction pauses or ends. Remote waterfall
    // providers use this to avoid resetting their stream on every zoom step.
    void frequencyRangeSettled(double centerMhz, double bandwidthMhz);
    // Emitted when the user drags a filter edge to resize the passband.
    void filterChangeRequested(int lowHz, int highHz);
    // Emitted when the user adjusts the dBm scale (drag or arrows).
    void dbmRangeChangeRequested(float minDbm, float maxDbm);
    void dbmRangeDragFinished(float minDbm, float maxDbm);
    void noiseFloorPositionResolved(int pos);
    void dssFloorDepthResolved(int dB);
    void waterfallLineDurationChangeRequested(int ms);
    void kiwiSdrDisplaySourceRequested(bool kiwi);
    // TNF signals
    void tnfCreateRequested(double freqMhz);
    void tnfMoveRequested(int id, double newFreqMhz);
    void tnfRemoveRequested(int id);
    void tnfWidthRequested(int id, int widthHz);
    void tnfDepthRequested(int id, int depthDb);
    void tnfPermanentRequested(int id, bool permanent);
    void sliceCreateRequested(double freqMhz);
    void sliceCloseRequested(int sliceId);
    void propForecastClicked();  // click on K/A/SFI overlay text
    void sliceTuneRequested(int sliceId, double freqMhz);
    void popOutRequested(bool popOut);  // true=float, false=dock
    void sliceTxRequested(int sliceId);
    void centerLockRequested(int sliceId, bool locked);
    // Emitted when FFT bin-mapping dimensions change so MainWindow can re-push
    // xpixels/ypixels to the radio (#1511).
    void dimensionsChanged(int w, int h);
    // Spot signals
    void spotAddRequested(double freqMhz, const QString& callsign,
                          const QString& comment, int lifetimeSec,
                          bool forwardToCluster);
    void spotRemoveRequested(int spotIndex);

protected:
#ifdef AETHER_GPU_SPECTRUM
    void initialize(QRhiCommandBuffer* cb) override;
    void render(QRhiCommandBuffer* cb) override;
    void releaseResources() override;
    // Keep the RHI color buffer at an even-aligned device-pixel size so a
    // fractional QT_SCALE_FACTOR (UiScalePercent ≠ 100) never hands the GPU
    // driver odd texture extents on resize (#4091).
    void updateFixedColorBufferSize();
#else
    void paintEvent(QPaintEvent* event) override;
#endif
    void resizeEvent(QResizeEvent* event) override;
    void mousePressEvent(QMouseEvent* event) override;
    void mouseMoveEvent(QMouseEvent* event) override;
    void mouseReleaseEvent(QMouseEvent* event) override;
    void mouseDoubleClickEvent(QMouseEvent* event) override;
    void wheelEvent(QWheelEvent* event) override;
    bool event(QEvent* event) override;
    bool eventFilter(QObject* watched, QEvent* event) override;
    void leaveEvent(QEvent* event) override;

public:
    void showAddSpotDialog(double freqMhz);

    // Starstruck easter egg: Ctrl+Shift+A toggles the panadapter pan-drag sound.
    static void toggleStarstruckMode();

private:
    void setFrequencyRangeInternal(double centerMhz, double bandwidthMhz,
                                   bool animateSmallNudges);
    double effectiveGridStepMhz(int widgetWidth) const;
    void drawGrid(QPainter& p, const QRect& r);
    void drawSpectrum(QPainter& p, const QRect& r);
    void drawSliceMarkers(QPainter& p, const QRect& specRect, const QRect& wfRect);
    // Draw each flag's SmartMTR extremes value labels on top of the slice markers.
    void drawSmartMtrValueLabels(QPainter& p);
    void drawOffScreenSlices(QPainter& p, const QRect& specRect);
    void drawBandPlan(QPainter& p, const QRect& specRect);
    void drawTnfMarkers(QPainter& p, const QRect& specRect);
    void drawSpotMarkers(QPainter& p, const QRect& specRect);
    void drawSwrSweep(QPainter& p, const QRect& specRect);
    void drawAutoSqlFloor(QPainter& p, const QRect& specRect);
    void drawSquelchLine(QPainter& p, const QRect& specRect);
    void updateAutoSquelchFromBins(const QVector<float>& binsDbm);
    QRect leftOccludedRect() const;
    void showSpotClusterPopup(const SpotCluster& cluster, const QPoint& globalPos);
    const TnfMarker* tnfMarkerById(int id) const;
    QColor tnfColor(const TnfMarker& tnf) const;
    QColor tnfFillColor(const TnfMarker& tnf) const;
    QColor tnfLineColor(const TnfMarker& tnf) const;
    int  tnfAtPixel(int x, int preferredId = -1) const;
    bool sliceCursorShapeAt(const QPoint& localPos, Qt::CursorShape& shape) const;
    bool spectrumDefaultsToCrosshairAt(const QPoint& localPos) const;
    void installVfoCursorEventFilter(VfoWidget* widget);
    void setVfoCursorOverride(Qt::CursorShape shape);
    void clearVfoCursorOverride();
    void applyActiveVfoZOrder();
#ifdef AETHER_GPU_SPECTRUM
    void repositionVfoFlags(const QRect& specRect);  // #3617 — shared flag positioner
#endif
    void setSpectrumCursor(Qt::CursorShape shape);
    void updateTrackedCursorState(const QPoint& localPos, bool insideWidget);
    void updateTnfHoverPopup();
    void drawWaterfall(QPainter& p, const QRect& r);
    void createFpsMeterLabels();
    void updateFpsMeterLabels();
    void updateFpsMeterSyncStatsLabel(bool force = false);
    void positionFpsMeterLabels();
    void positionZoomButtons();
    void drawFreqScale(QPainter& p, const QRect& r);
    void drawDbmScale(QPainter& p, const QRect& specRect);
    // Shared strip chrome (background, border, ref-adjust arrows) for both the
    // 2D linear dBm scale and the 3D stacked-trace amplitude scale, so the
    // strip's geometry and click targets are identical in either render mode.
    void drawDbmScaleChrome(QPainter& p, const QRect& specRect);
    // Shared full-height LINEAR dBm tick labels: topDbm at specRect.top(),
    // topDbm-rangeDb at the baseline, evenly spaced.
    void drawDbmScaleLabels(QPainter& p, const QRect& specRect,
                            float topDbm, float rangeDb);
    // Full-height dBm amplitude reference for 3D stacked-trace mode. It follows
    // the floor anchor and scale span; perspective means individual history rows
    // do not share a single pixel-exact y-axis.
    void drawDbmScale3D(QPainter& p, const QRect& specRect, float floorDbm);
    void drawTimeScale(QPainter& p, const QRect& wfRect);
    void drawConnectionAnimation(QPainter& p, const QRect& contentRect);
    void positionPanadapterMessageOverlay();
    void raisePanadapterMessageOverlay();
    int waterfallStripWidth() const;
    QRect waterfallLiveButtonRect(const QRect& wfRect) const;
    QRect waterfallTimeScaleRect(const QRect& wfRect) const;
    void ensureWaterfallHistory();
    void rebuildWaterfallViewport();
    void rebuildWaterfallViewportForFrame(double centerMhz, double bandwidthMhz);
    void rebuildDssViewportFromHistory();
    void rebuildDssViewportFromHistoryForFrame(double centerMhz, double bandwidthMhz);
    void setWaterfallLive(bool live);
    void handleWaterfallFrequencyFrameChange(double oldCenterMhz,
                                             double oldBandwidthMhz,
                                             double newCenterMhz,
                                             double newBandwidthMhz);
    void applyPanDragCenter(double newCenterMhz, bool force);
    void beginPanDrag(int startX);
    void schedulePanDragDeferredUpdate();
    void schedulePanDragSettleUpdate();
    void scheduleFrequencyRangeSettleUpdate(double centerMhz, double bandwidthMhz);
    void finishFrequencyRangeSettleUpdate();
    struct WaterfallStreamState {
        QImage waterfall;
        int wfWriteRow{0};
        QImage waterfallHistory;
        QVector<qint64> historyTimestamps;
        int historyWriteRow{0};
        int historyRowCount{0};
        int historyOffsetRows{0};
        QVector<double> historyRowCenterMhz;
        QVector<double> historyRowBwMhz;
        bool live{true};
        int rowsSinceRateChange{0};
        QVector<QRgb> prevTileScanline;
        QVector<float> kiwiFftTrace;
        QVector<quint8> kiwiFftFallbackSeedMask;
        QVector<float> kiwiLastWaterfallBins;
        double kiwiLastWaterfallCenterMhz{0.0};
        double kiwiLastWaterfallBandwidthMhz{0.0};
        bool kiwiLastWaterfallFrameValid{false};
        DssRenderer dss;
        float kiwiDisplayFloorDbm{-110.0f};
        float kiwiDisplayCeilDbm{-10.0f};
        bool kiwiDisplayRangeValid{false};
        bool kiwiDisplayRangeAutoRange{false};
        float kiwiFftTraceFloorDbm{-1000.0f};
        bool kiwiFftTraceFloorValid{false};
        bool valid{false};
#ifdef AETHER_GPU_SPECTRUM
        bool wfTexFullUpload{true};
        int wfLastUploadedRow{-1};
        bool dssTexNeedsUpload{true};
        quint64 dssLastUploadedGen{~0ull};
        int dssMeshHeadUploaded{-1};
        quint64 dssMeshRowGenUploaded{~0ull};
#endif
    };
    void clearCurrentWaterfallRows();
    void resetKiwiSdrWaterfallDisplayRange();
    void resetCurrentWaterfallRowsForSize(const QSize& waterfallSize,
                                          const QSize& historySize);
    void saveCurrentWaterfallStreamState();
    void restoreCurrentWaterfallStreamState();
    void discardRetainedHistory(WaterfallStreamState& state);
    WaterfallStreamState& activeKiwiWaterfallState();
    const WaterfallStreamState* activeKiwiWaterfallStateConst() const;
    bool beginWaterfallStreamWrite(bool kiwiStream);
    void endWaterfallStreamWrite(bool kiwiStream, bool visibleStream);
    void appendHistoryRow(const QRgb* rowData, qint64 timestampMs,
                          double frameCenterMhz = -1.0,
                          double frameBandwidthMhz = -1.0);
    void appendDssHistoryRow(const QVector<float>& binsDbm,
                             double frameCenterMhz = -1.0,
                             double frameBandwidthMhz = -1.0);
    void appendDssWaterfallRow(const QVector<float>& binsDbm,
                               double frameCenterMhz = -1.0,
                               double frameBandwidthMhz = -1.0,
                               bool updateLiveSurface = true);
    void appendLatestDssWaterfallRow(double frameCenterMhz = -1.0,
                                     double frameBandwidthMhz = -1.0);
    void pushDssLiveRow(DssRenderer& dss, const QVector<float>& binsDbm,
                        bool hiddenStream);
    void retainDssHistoryRow(DssRenderer& dss, const QVector<float>& binsDbm,
                             double centerMhz, double bandwidthMhz,
                             float fallbackDbm);
    float dssHistoryFallbackDbm() const;
    void appendVisibleRow(const QRgb* rowData);
    int waterfallHistoryCapacityRows() const;
    int maxWaterfallHistoryOffsetRows() const;
    int historyRowIndexForAge(int ageRows) const;
    QString pausedTimeLabelForAge(int ageRows) const;
    void updateWaterfallMsPerRowFromHistory();
    int waterfallFallbackIntervalMs() const;
    int waterfallFallbackTimeoutMs() const;
    int nativeWaterfallFallbackHoldMs(int intervalMs) const;
    void updateNativeWaterfallFallbackState(qint64 nowMs);
    bool pushRxWaterfallFallbackIfDue(const QVector<float>& bins, qint64 nowMs);
    void applyFpsMeterVisibility(bool on);
    void resetFpsMeterWindow();
    void updateFpsMeterValues();
    void recordPanadapterFrame();
    void recordWaterfallFrame(int rows = 1);
    void recordKiwiSdrWaterfallFrame(int rows = 1);
    bool anyDragActive() const;
    void publishPerfDragState() const;

    // Two-pass trimmed-mean noise-floor estimator: pass 1 takes the
    // overall mean across the bins (stride-sampled for speed), pass 2
    // averages only bins ≤ that mean.  Signal peaks inflate the pass-1
    // mean and therefore exclude themselves from pass 2, leaving the
    // flat noise baseline that a human eye reads as the "noise floor"
    // on the scope.
    float estimateNoiseFloorDbm(const QVector<float>& bins) const;

    // Update the smoothed-baseline tracker for the noise-floor auto-
    // adjust path.  Per-frame, asymmetric smoothing (drops follow
    // quickly, rises slowly), with a candidate-state transient filter
    // so brief upward spikes (lightning crashes) don't pull the lock.
    bool updateNoiseFloorBaseline(const QVector<float>& bins, bool forceBaseline);
    float estimateKiwiSdrVisualNoiseFloorDbm(const QVector<float>& bins) const;
    float estimateKiwiSdrTraceFloorDbm(const QVector<float>& bins) const;
    void stabilizeKiwiSdrFftTrace(QVector<float>& bins, bool allowFloorAdapt);
    void updateKiwiSdrSquelchVisualFloor(float floorDbm);
    void pinKiwiSdrManualSquelchLine();
    // Adjust m_refLevel toward the target so the smoothed noise floor
    // sits at m_noiseFloorPosition.  Pans the dB range (keeps span
    // fixed) rather than zooming it (existing zoom-when-floor-moves
    // semantic was jarring — span changes shifted signal visual heights
    // every time the floor drifted).
    void applyNoiseFloorAutoAdjust(qint64 nowMs);
    bool noiseFloorAutoAdjustHeld(qint64 nowMs);
    void armNoiseFloorFastLock(int freshFrames, int snapFrames);
    void moveRefLevelToward(float targetRef, qint64 nowMs);
    void sendNoiseFloorRangeCommand(qint64 nowMs, bool force);
    void clearDbmReleaseRebase();
    // Reset the baseline tracker — called on any input change (zoom,
    // band switch, manual dBm drag) so the next frame re-acquires
    // rather than smooths from a stale value.
    void resetNoiseFloorBaseline();
    void reacquireNoiseFloorLockFromVisibleSource();
    // Re-capture the target frac. Explicit user changes (slider/right dBm bar)
    // can persist; startup/enable/layout refreshes only rebuild transient state.
    void refreshNoiseFloorTarget(bool captureCurrentScale = false, bool persistCapture = false);
    bool captureNoiseFloorTargetFromCurrentScale(bool notify, bool persist);
    QString displaySourceTraceSettingsKey() const;
    void loadDisplaySourceTraceSettings(int legacyNoiseFloorPosition,
                                        int legacyDssFloorDepth);
    void saveDisplaySourceTraceSettings();
    void setNoiseFloorPositionForSource(bool kiwiSource, int pos, bool persist);
    void restoreNoiseFloorPositionForCurrentSource(bool syncMenu);
    void setDssFloorDepthForSource(bool kiwiSource, int dB, bool persist);
    void restoreDssFloorDepthForCurrentSource(bool syncMenu);

    // Helper: find overlay index for a sliceId, or -1.
    int overlayIndex(int sliceId) const;
    // Helper: find active overlay (or nullptr).
    const SliceOverlay* activeOverlay() const;
    // Helper: find TX overlay (or nullptr).
    const SliceOverlay* txOverlay() const;
    bool txWaterfallMaskRange(double& lowMhz, double& highMhz) const;
    bool txWaterfallAffectsThisPan() const;
    void beginTxDbmRangeFreeze();
    void endTxDbmRangeFreeze();
    void resetTxDbmRangeFreeze();
    void deferTxDbmRange(float minDbm, float maxDbm);
    void applyDbmRangeImmediate(float minDbm, float maxDbm);
    void reprojectBinsToFrozenTxDbmRange(QVector<float>& bins) const;
    void clearWaterfallRows();
    QVector<float> smoothKiwiSdrWaterfallBins(const QVector<float>& bins);
    const QVector<float>& displaySpectrumBins() const;
    // Returns a reference into shared mutable scratch — valid only until the
    // next call. Consume the result before invoking again; never hold two live.
    const QVector<float>& buildFftDisplayTrace(const QVector<float>& bins,
                                               int targetPoints) const;
    const QVector<float>& noiseFloorAutoLevelBins() const;

    void pushWaterfallRow(const QVector<float>& bins, int destWidth,
                          double tileLowMhz = -1, double tileHighMhz = -1);
    void pushKiwiSdrWaterfallRow(const QVector<float>& bins, int destWidth,
                                 double rowCenterMhz, double rowBandwidthMhz);
    QRgb dbmToRgb(float dbm) const;
    QRgb kiwiSdrLevelToRgb(float level) const;
    QRgb intensityToRgb(float intensity) const;  // for native waterfall tiles
    // 3DSS surface colour for a normalised strength s in [0,1] (0 = noise floor,
    // 1 = ref). The full colormap gradient, gamma-shaped by the "3D Gain"
    // control. Shared by the GPU LUT bake and the CPU fallback so both paths
    // colour identically (deliberately NOT dbmToRgb(), whose waterfall
    // black-level window clipped the lower range to black).
    QRgb dssStrengthToRgb(float s) const;

    // 3DSS — rebuild/return the cached perspective surface for the given pixel
    // size (scaleStripPx = transparent frequency-scale strip at the bottom).
    const QImage& buildDssImage(const QSize& px, int scaleStripPx,
                                float floorDbm);
    void pushDssRowForWaterfallStream(bool kiwiStream,
                                      const QVector<float>& binsDbm,
                                      double frameCenterMhz = -1.0,
                                      double frameBandwidthMhz = -1.0,
                                      bool updateLiveSurface = true);
    void resetDssUploadState();
    // Token folding the dbmToRgb() palette inputs so the 3DSS cache rebuilds
    // when the colour mapping (scheme/gain/floor) changes.
    quint64 dssPaletteToken() const;
    // Unified noise-floor anchor (dBm, quantised) for the 3D surface.
    float dssFloorDbm();
    float peekDssFloorDbm() const;
    // dB span shown above the 3D floor anchor — follows the normal dBm scale,
    // clamped so the wide Flex window can't flatten signals.
    float dssSpanDb() const;

    // Pixel x coordinate for a given frequency in MHz (0 = left edge).
    int mhzToX(double mhz) const;
    // Convert pixel x back to MHz.
    double xToMhz(int x) const;

    QVector<float> m_bins;       // raw FFT frame (dBm)
    QVector<float> m_smoothed;   // exponential-smoothed for visual stability
    QVector<quint8> m_fftFallbackSeedMask; // 1 = replace from next real FFT frame
    mutable QVector<float> m_fftDisplaySmoothScratch;
    mutable QVector<float> m_fftDisplayTraceScratch;
    QVector<float> m_kiwiSdrFftTrace;  // Kiwi-derived FFT trace, kept separate from Flex FFT
    QVector<quint8> m_kiwiSdrFftFallbackSeedMask; // 1 = replace from next real Kiwi row
    bool m_shutdownPrepared{false};
    bool m_kiwiSdrWaterfallAvailable{false};
    bool m_kiwiSdrWaterfallActive{false};
    PanadapterMessageOverlay* m_panadapterMessageOverlay{nullptr};
    WaterfallStreamState m_nativeWaterfallState;
    WaterfallStreamState m_kiwiWaterfallState;
    QHash<QString, WaterfallStreamState> m_kiwiProfileWaterfallStates;
    // True while the current waterfall state is the operator-visible source.
    // Hidden Flex/Kiwi updates temporarily swap their state into the current
    // fields; instrumentation and retention policy need to distinguish that
    // background work from visible work (#4081).
    bool m_waterfallWriteVisible{true};
    QString m_kiwiSdrWaterfallProfileId;
    QVector<float> m_kiwiSdrLastWaterfallBins;
    double m_kiwiSdrLastWaterfallCenterMhz{0.0};
    double m_kiwiSdrLastWaterfallBandwidthMhz{0.0};
    bool m_kiwiSdrLastWaterfallFrameValid{false};
    float m_kiwiSdrDisplayFloorDbm{-110.0f};
    float m_kiwiSdrDisplayCeilDbm{-10.0f};
    bool m_kiwiSdrDisplayRangeValid{false};
    bool m_kiwiSdrDisplayRangeAutoRange{false};
    float m_kiwiSdrFftTraceFloorDbm{-1000.0f};
    bool m_kiwiSdrFftTraceFloorValid{false};

    double m_centerMhz{14.225};
    double m_bandwidthMhz{0.200};
    // Pan-follow smooth animation (#989): animates m_centerMhz toward the target
    // for small nudges so the VFO widget glides instead of snapping.
    QVariantAnimation* m_panCenterAnim{nullptr};
    double             m_panCenterTarget{14.225};
    double             m_panCenterStart{14.225}; // m_centerMhz at animation start (stale-echo guard)

    // Multi-slice overlays (replaces single m_vfoFreqMhz / m_filterLowHz / etc.)
    QVector<SliceOverlay> m_sliceOverlays;
    int m_centerLockSliceId{-1};

    int    m_filterMinHz{-12000};  // per-mode lower bound (active slice)
    int    m_filterMaxHz{12000};   // per-mode upper bound (active slice)
    QString m_mode{"USB"};         // current demod mode (active slice)

    float m_refLevel{-50.0f};       // top of display (dBm)
    float m_dynamicRange{100.0f};   // dB range shown in spectrum (-50 to -150)
    bool  m_resetFftSmoothingOnNextFrame{false};
    bool  m_pendingDbmRangeEcho{false};
    bool  m_pendingDbmRangeEchoFromAutoFloor{false};
    qint64 m_pendingDbmRangeEchoStartMs{0};
    int   m_holdFftUpdatesAfterDbmRelease{0};
    float m_dbmReleasePreviewOldMinDbm{0.0f};
    float m_dbmReleasePreviewOldMaxDbm{0.0f};
    float m_dbmReleasePreviewNewMinDbm{0.0f};
    float m_dbmReleasePreviewNewMaxDbm{0.0f};
    float m_pendingMinDbm{0.0f};
    float m_pendingMaxDbm{0.0f};

    // Two-pass trimmed-mean noise floor (dBm), EMA-smoothed across ~20 frames.
    // -1000 = cold start (not yet measured).
    float m_measuredNoiseFloorDbm{-1000.0f};

    // Noise floor auto-adjust
    bool  m_noiseFloorEnable{false};
    int   m_noiseFloorPosition{75};  // 1=top, 99=bottom
    int   m_flexNoiseFloorPosition{75};
    int   m_kiwiNoiseFloorPosition{75};
    int   m_noiseFloorFrameCount{0};
    // Noise-floor auto-adjust state machine (per-frame baseline tracker
    // with asymmetric smoothing + transient rejection — keeps the floor
    // visually pinned at m_noiseFloorPosition without chasing lightning
    // crashes).
    bool   m_noiseFloorBaselineValid{false};
    bool   m_noiseFloorTargetValid{false};
    float  m_noiseFloorBaselineDbm{-1000.0f};
    float  m_noiseFloorTargetFrac{0.75f};
    qint64 m_noiseFloorLastSampleMs{0};
    qint64 m_noiseFloorLastMotionMs{0};
    qint64 m_noiseFloorLastCommandMs{0};
    qint64 m_noiseFloorScaleSettlingUntilMs{0};
    qint64 m_noiseFloorAutoAdjustHoldUntilMs{0};
    float  m_noiseFloorLastCommandRef{-1000.0f};
    bool   m_noiseFloorCandidateValid{false};
    float  m_noiseFloorCandidateDbm{-1000.0f};
    qint64 m_noiseFloorCandidateStartMs{0};
    int    m_noiseFloorCandidateFrames{0};
    int    m_noiseFloorFreshFrameCount{0};
    int    m_noiseFloorFastLockFrames{0};

    // Percentile EWMA used for the amber floor overlay line and auto-squelch.
    // Tracked separately from m_measuredNoiseFloorDbm (two-pass trimmed mean)
    // so the auto-adjust display feature and auto-squelch are independent.
    // Squelch threshold overlay lines. Flex and KiwiSDR keep independent
    // state because they can be controlled from different receive surfaces.
    bool  m_flexSquelchLineVisible{false};
    int   m_flexSquelchLevel{0};
    bool  m_kiwiSdrSquelchLineVisible{false};
    int   m_kiwiSdrSquelchLevel{0};
    bool  m_kiwiSdrSquelchLineFloorRelative{false};
    float m_kiwiSdrSquelchLiveFloorDbm{-999.0f};
    float m_kiwiSdrSquelchPinnedFloorDbm{-999.0f};
    bool  m_kiwiSdrSquelchPinnedFloorValid{false};
    float m_kiwiSdrSquelchPinnedThresholdDbm{-999.0f};
    bool  m_kiwiSdrSquelchPinnedThresholdValid{false};
    float m_kiwiSdrSquelchPinnedDisplayNorm{-1.0f};
    bool  m_kiwiSdrSquelchPinnedDisplayNormValid{false};
    bool  m_kiwiSdrSquelchMeterFloorValid{false};
    QVector<float> m_kiwiSdrSquelchMeterSamples;
    QTimer* m_squelchLineHideTimer{nullptr}; // auto-hides yellow line 3 s after enable/adjust (manual SQL only)
    bool  m_autoSquelchEnabled{false};
    float m_sqlNoiseFloorDbm{-999.0f};  // auto-squelch own two-pass trimmed-mean EWMA
    // dBm above noise floor for auto-squelch suggestion (5-20, default 10)
    int   m_autoSqlMarginDb{10};
    int   m_lastAutoSquelchLevel{-1};    // dedup — only emit when level changes

    // Tuning step size for click-snap and wheel scroll (Hz)
    int m_stepHz{100};
    int m_scrollAccum{0};   // trackpad pixel scroll accumulator (macOS)
    int m_angleAccum{0};    // mouse wheel angle accumulator (#390)
    qint64 m_lastWheelMs{0}; // debounce: timestamp of last accepted wheel step

    // Starstruck easter egg (Ctrl+Shift+A) — shared across all instances
    static bool s_starstruckMode;
    static QSoundEffect* s_starstruckSound;
    static void ensureStarstruckSoundLoaded();

    // Panadapter bandwidth zoom limits (MHz), set per-radio model
    double m_minBwMhz{0.010};   // 10 kHz default
    double m_maxBwMhz{5.400};   // safe default for unknown radios

    // ── FFT display controls (radio-side via "display pan set") ──────────
    int   m_panIndex{0};             // per-pan settings index (0, 1, 2, 3)
    int   m_fftAverage{0};           // 0=off, 1-10 frames
    bool  m_fftWeightedAvg{false};
    int   m_fftFps{25};
    float m_fftFillAlpha{0.70f};     // client-side fill opacity (0-1)
    QColor m_fftFillColor{0x00, 0xe5, 0xff};  // client-side fill color (default cyan)
    bool m_fftHeatMap{true};        // true = intensity heat map, false = solid color
    bool m_showGrid{true};          // false = hide grid lines
    int  m_freqGridSpacingKhz{0};   // 0=Auto, or 1/2/5/10/25/50/100 kHz (#1390)
    int  m_freqScaleFontPt{8};      // freq-scale label size, 8..14 pt (#3501)
    float m_fftLineWidth{2.0f};     // spectrum trace width in pixels

    // ── Waterfall display controls (radio-side via "display panafall set") ─
    int   m_wfColorGain{50};         // 0-100, maps intensity to color range
    int   m_wfBlackLevel{15};        // 0-125, intensity floor (below = black)
    bool  m_wfAutoBlack{true};
    // Auto-black offset (0-100). 50 → no offset (today's behaviour); <50
    // pushes the threshold above the noise floor (darker waterfall); >50
    // pulls it below (lighter).  Stored separately from m_wfBlackLevel so
    // toggling AUTO swaps between the two without losing either value.
    int   m_wfAutoBlackOffset{50};
    // Auto-black source: false = client-side noise-floor estimate (default,
    // legacy look); true = the radio's per-tile auto-black level.
    bool  m_wfAutoBlackRadioSide{false};
    WfColorScheme m_wfColorScheme{WfColorScheme::Default};

    // 3DSS — perspective stacked-trace render mode. m_dss owns the rolling
    // history + cached surface image; consumed by both the CPU and GPU paths.
    SpectrumRenderMode m_spectrumRenderMode{SpectrumRenderMode::Mode2D};
    // GUI-thread only: pushRow() (updateSpectrum / updateKiwiSdrWaterfallRow) and
    // the renderGpuFrame/paint reads all run on the GUI thread, so m_dss needs no
    // lock. Do NOT call pushRow() from a worker/audio thread without adding one.
    DssRenderer   m_dss;
    // 3DSS height anchor: the measured noise floor maps this many dB below the
    // trace baseline. A few dB negative lifts the noisy floor carpet (with its
    // own colour) up off the baseline so you see floor -> peak, not just crests.
    float m_dssFloorOffsetDb{-6.0f};
    int   m_flexDssFloorDepth{6};
    int   m_kiwiDssFloorDepth{6};
    int   m_dssGain{70};   // 3DSS colour floor 0-100 (gamma of palette lookup)
    float m_dssFloorAnchorDbm{-1000.0f};
    bool  m_dssFloorAnchorValid{false};
    // Consumed by BOTH the GPU mesh and the CPU fallback surface, so these stay
    // outside the AETHER_GPU_SPECTRUM block below — the CPU paint path needs them
    // even when GPU spectrum rendering is disabled (older Qt / -DAETHER_GPU_SPECTRUM=OFF).
    float m_dssZCurve{0.70f};               // <1 expands the floor band (more floor)
    static constexpr int kDssMaxW = 1024;   // 3DSS surface texture/image caps
    static constexpr int kDssMaxH = 512;

    float m_autoBlackThresh{145.0f}; // client-side auto-black: tracked noise floor
    // Radio's per-tile auto-black level (raw uint16). Preferred over the client
    // estimate when non-zero; matches FlexLib's auto-level pipeline.
    float m_radioAutoBlackRaw{0.0f};
    int   m_wfLineDuration{100};     // ms per waterfall row

    // Waterfall colour range for FFT-derived fallback (dBm).
    float m_wfMinDbm{-130.0f};
    float m_wfMaxDbm{-50.0f};

    // Scrolling waterfall image (Format_RGB32)
    QImage m_waterfall;
    int    m_wfWriteRow{0};  // ring buffer: next row to write (newest at top)
    QImage m_waterfallHistory;
    QSize  m_waterfallStreamSizeHint;
    QSize  m_waterfallHistoryStreamSizeHint;
    QVector<qint64> m_wfHistoryTimestamps;
    int    m_wfHistoryWriteRow{0};
    int    m_wfHistoryRowCount{0};
    int    m_wfHistoryOffsetRows{0};
    // Per-row frequency frame: each history row records the center/bandwidth it
    // was captured at (parallel to m_wfHistoryTimestamps). The full history image
    // (up to ~24k rows, ~0.5 GB at ultrawide widths) is therefore never globally
    // reprojected on a pan — instead rebuildWaterfallViewport remaps only the
    // ~700 visible rows from their own frame to the requested viewport on live
    // pan/zoom changes and time-scrollback.
    QVector<double> m_wfHistoryRowCenterMhz;
    QVector<double> m_wfHistoryRowBwMhz;
    bool   m_wfLive{true};
    bool   m_draggingTimeScale{false};
    bool   m_draggingTimeScaleRate{false};
    int    m_timeScaleDragStartY{0};
    int    m_timeScaleDragStartOffsetRows{0};
    int    m_timeScaleDragStartRatePercent{1};
    static constexpr qint64 kWaterfallHistoryMs = 20LL * 60LL * 1000LL;

    // True while native waterfall tile data (PCC 0x8004) is arriving on the
    // expected cadence.  RX uses paced FFT-derived rows only as a stale-native
    // fallback so slow requested waterfall rates do not look falsely timed out.
    bool m_hasNativeWaterfall{false};
    qint64 m_lastNativeTileMs{0};    // timestamp of last native tile (for fallback)
    bool m_waterfallFallbackActive{false};
    qint64 m_nextFallbackWaterfallRowMs{0};
    // Before the first native tile, or after a rate change, hold fallback
    // briefly so fast previews do not flash FFT rows while native data catches up.
    qint64 m_nativeWaterfallFallbackHoldUntilMs{0};
    QVector<QRgb> m_prevTileScanline;  // previous tile row for interpolation

    static constexpr float SMOOTH_ALPHA    = 0.35f;
    // Fraction of the panadapter area (above freq scale) used for spectrum
    float m_spectrumFrac{0.40f};
    // Height of the frequency scale bar at the default 8 pt label size.
    // Use freqScaleH() in layout code — it grows with the user's label
    // font so larger text never clips (#3501).
    static constexpr int   FREQ_SCALE_H    = 20;
    int freqScaleH() const;
    // Height of the draggable divider between FFT and freq scale
    static constexpr int   DIVIDER_H       = 4;
    // Divider drag state
    bool m_draggingDivider{false};
    // Bandwidth drag state (freq scale bar)
    bool m_draggingBandwidth{false};
    int  m_bwDragStartX{0};
    double m_bwDragStartBw{0.0};
    double m_bwDragAnchorMhz{0.0};
    bool m_frequencyRangeSettlePending{false};
    bool m_frequencyRangePendingValid{false};
    double m_frequencyRangePendingCenterMhz{0.0};
    QTimer* m_frequencyRangeSettleTimer{nullptr};
    // Waterfall pan drag state
    bool m_draggingPan{false};
    int  m_panDragStartX{0};
    double m_panDragStartCenter{0.0};
    double m_panDragWaterfallFrameCenterMhz{0.0};
    double m_panDragLastCommandCenterMhz{0.0};
    double m_panDragPendingCenterMhz{0.0};
    bool m_panDragPendingCenterValid{false};
    bool m_panDragDeferredUpdateScheduled{false};
    QElapsedTimer m_panDragWaterfallClock;
    QElapsedTimer m_panDragCommandClock;
    QTimer* m_panDragSettleTimer{nullptr};
    // Filter edge drag state
    enum class FilterEdge { None, Low, High };
    FilterEdge m_draggingFilter{FilterEdge::None};
    int m_filterDragStartX{0};      // pixel X at grab time (#764)
    int m_filterDragStartHz{0};     // filter edge Hz at grab time (#764)
    QElapsedTimer m_presentCoalesceClock;   // data-repaint coalescing clock
    // Coalescing window: FFT frames and waterfall rows arrive as
    // separate UDP events, so without coalescing a narrow pan schedules up to
    // ~56 window flushes/s (30 fps FFT + 26 rows/s WF) — over the display's
    // 60 Hz budget once WAVE/meters add theirs, which starves the swapchain
    // drawable pool and blocks the GUI thread in nextDrawable (#3938 class).
    // One present per 16 ms slot keeps every data frame (a trailing update
    // fires at the slot edge) while capping flushes at ~60/s.
    static constexpr int kPresentCoalesceMs = 16;
    bool m_presentPending{false};           // trailing update scheduled
    // Shared cross-pan repaint coalescer (owned by PanadapterStack). QPointer so
    // a teardown reorder can't leave a dangling scheduler here. (#4139)
    QPointer<PanadapterRenderScheduler> m_renderScheduler;
    void coalescedUpdate();                 // update(), coalesced into one present per slot
    // VFO passband drag state (#404)
    bool m_draggingVfo{false};
    int  m_vfoDragOffsetHz{0};  // Hz offset from VFO at grab point (#1120)
    // Continuous edge auto-pan during VFO drag (user-reported).  The edge-follow
    // pan (revealFrequencyIfNeeded) is a *position* controller — it nudges the
    // slice a little past the trigger margin — not a *velocity* controller, so
    // holding the cursor at the border produced a tiny, self-limiting creep
    // (~0.1×span/s, the "rubber band" feel): the overshoot can't grow because
    // the cursor can't move past the physical border.  Instead, while the
    // cursor sits in the edge zone a timer drives a real pan *velocity* that
    // scales with edge depth and ramps up with hold time, panning the view and
    // keeping the slice pinned under the cursor via edgePanTuneRequested (a
    // pan-without-reveal path, so it doesn't fight the follow logic).
    QTimer* m_vfoDragEdgePanTimer{nullptr};
    int  m_vfoDragLastX{0};                 // last cursor X during VFO drag (px)
    int  m_vfoDragEdgeHoldTicks{0};         // ticks held in edge zone (ramp)
    qint64 m_vfoDragPanEchoHoldUntilMs{0};  // ignore stale center echoes briefly after drag
    bool m_vfoDragEdgePanDisabled{false};   // AETHER_NO_DRAG_EDGEPAN=1 escape hatch
    // Velocity knobs, env-tunable so the feel can be swept WITHOUT rebuilding:
    //   AETHER_DRAG_EDGEPAN_VMAX     — top speed, % of span width per second
    //   AETHER_DRAG_EDGEPAN_RAMP     — ms held to ramp from 0 → top speed
    //   AETHER_DRAG_EDGEPAN_INTERVAL — timer interval ms (~30 Hz default)
    int  m_edgePanVmaxPctBw{120};
    int  m_edgePanRampMs{600};
    int  m_edgePanIntervalMs{33};
    // Edge zone width as a fraction of widget width (matches the incremental
    // pan-follow trigger margin kIncrementalTriggerEdgeMarginFrac=0.05).
    static constexpr double kVfoDragEdgeZoneFrac = 0.05;
    void driveVfoDragTune(int mx, const char* phase);  // normal in-window tune
    bool updateVfoDragEdgePan(int mx);                 // → true if in edge zone
    void edgePanVelocityStep();                        // timer tick: velocity pan
    // dBm scale strip drag state
    static constexpr int DBM_STRIP_W = 36;  // width of the dBm scale strip
    static constexpr int DBM_ARROW_H = 14;  // height of each arrow button
    bool  m_draggingDbm{false};
    bool  m_draggingDbmRange{false};
    bool  m_draggingDssFloor{false};
    int   m_dbmDragStartY{0};
    float m_dbmDragStartRef{0.0f};
    float m_dbmDragStartRange{0.0f};
    float m_dbmDragStartBottom{0.0f};
    int   m_dssFloorDragStartDepth{0};
    // Off-screen slice indicator hit rects (parallel to m_sliceOverlays)
    QVector<QRect> m_offScreenRects;
    int  m_hoveringOffScreenIdx{-1};

    // On-screen indicators (WNB, RF Gain)
    bool m_wnbActive{false};
    bool m_wnbUpdating{false};
    int  m_rfGainValue{0};
    bool m_wideActive{false};

    // HF propagation forecast overlay
    bool m_propForecastVisible{false};
    double m_propKIndex{-1.0};
    QRect  m_propClickRect;  // bounding rect of rendered prop text for click detection
    QRect  m_indicatorStripRect;  // bounding rect of full top-right indicator strip
                                  // (prop + WNB + RF Gain + WIDE) — single-click tune
                                  // is suppressed inside this rect (#1564)
    int  m_propAIndex{-1};
    int  m_propSfi{-1};

    // MQTT device status overlay
    QMap<QString, QString> m_mqttDisplayValues;

    // Background image
    QImage  m_bgImage;
    QImage  m_bgScaled;     // cached at current specRect size
    QString m_bgImagePath;
    QSize   m_bgScaledSize;
    int     m_bgOpacity{80};  // 0=full image, 100=solid dark (default 80%)
    // Solid fill colour painted BENEATH the bg image (#1741).  Default
    // matches the pre-feature compositing colour so visual is unchanged
    // until the operator picks something else via the spectrum overlay
    // menu's "Background:" colour swatch.
    QColor  m_bgFillColor{QColor(0x0a, 0x0a, 0x14)};

    // Cursor frequency label
    bool   m_showCursorFreq{false};
    QPoint m_cursorPos{-1, -1};

    // Tune guide overlay (vertical line + freq label, auto-hides after 4s)
    bool    m_showTuneGuides{false};
    bool    m_extendedFrequencyLine{false};
    bool    m_extendedPassband{false};
    bool    m_isFloating{false};
    bool    m_tuneGuideVisible{false};
    QTimer* m_tuneGuideTimer{nullptr};
    bool    m_connectionAnimationVisible{false};
    QString m_connectionAnimationLabel;
    QTimer* m_connectionAnimationTimer{nullptr};
    QElapsedTimer m_connectionAnimationClock;

    // State change detector cache (per-instance, NOT static — multiple
    // panadapters have different values and static vars cause an infinite
    // render loop that starves the event loop)
    double m_lastDetectCenter{0};
    double m_lastDetectBw{0};
    float  m_lastDetectRef{0};
    float  m_lastDetectDyn{0};
    float  m_lastDetectFrac{0};
    bool   m_lastDetectWnb{false};
    bool   m_lastDetectWnbUpdating{false};
    int    m_lastDetectRfGain{0};
    bool   m_lastDetectWide{false};
    // 3DSS only: the dBm scale is anchored to the (drifting) noise floor, so a
    // floor change must redraw the cached overlay even when nothing else did.
    float  m_lastDetectDssFloor{-1000.0f};

    // NB Waterfall Blanker (#277)
    bool  m_wfBlankerEnabled{false};
    int   m_wfBlankerMode{0};            // 0=Fill, 1=Interpolate
    float m_wfBlankerThreshold{1.15f};   // impulse multiplier vs rolling baseline
    static constexpr int WF_BLANKER_N = 32;
    float m_wfBlankerRing[WF_BLANKER_N]{};
    int   m_wfBlankerRingIdx{0};
    int   m_wfBlankerRingCount{0};
    QVector<QRgb> m_wfLastGoodRow;
    int  m_bandPlanFontSize{6};  // 0 = off
    bool m_bandPlanShowSpots{true};
    BandPlanManager* m_bandPlanMgr{nullptr};
    bool m_singleClickTune{false};
    QPoint m_clickPressPos;        // for single-click-to-tune drag threshold
    bool   m_spotClickConsumed{false}; // suppress release-to-tune after spot click (#530)
    bool m_showTxInWaterfall{false};  // default matches radio default (off)
    bool m_hasTxSlice{false};  // true if this pan contains the TX slice
    bool m_txWaterfallSliceValid{false};
    double m_txWaterfallFreqMhz{0.0};
    int m_txWaterfallFilterLowHz{0};
    int m_txWaterfallFilterHighHz{0};
    bool m_txWaterfallXitOn{false};
    int m_txWaterfallXitFreq{0};

    bool m_txDbmRangeFrozen{false};
    float m_txFrozenMinDbm{0.0f};
    float m_txFrozenMaxDbm{0.0f};
    float m_txSourceMinDbm{0.0f};
    float m_txSourceMaxDbm{0.0f};
    bool m_txDeferredDbmRangeValid{false};
    float m_txDeferredMinDbm{0.0f};
    float m_txDeferredMaxDbm{0.0f};

    bool     m_transmitting{false};
    float    m_preTxAutoBlack{145.0f}; // auto-black threshold saved before TX
    qint64   m_txEndMs{0};             // post-TX blanking: timestamp of TX→RX transition (#2117)

    // Waterfall time scale: ms-per-row is seeded from the requested rate and
    // corrected from real appended-row timestamps once the current rate has
    // enough samples.  Per-rate measurements are cached so later drags can use
    // the observed cadence immediately without re-jittering the visible scale.
    float    m_wfMsPerRow{100.0f};
    quint32  m_wfPrevTimecode{0};      // previous tile timecode (frame counter)
    qint64   m_wfPrevTimecodeMs{0};    // wall-clock time of previous timecode
    int      m_wfCalibrationCount{0};  // tiles measured so far
    bool     m_wfTimeScaleLocked{false};
    bool     m_wfHasMeasuredMsPerRow{false};
    int      m_wfLastMeasuredLineDurationMs{100};
    float    m_wfLastMeasuredMsPerRow{100.0f};
    qint64   m_wfCalibrationResumeMs{0};
    int      m_wfRowsSinceRateChange{0};
    QHash<int, float> m_wfMeasuredMsPerRowByLineDuration;
    QHash<int, int>   m_wfMeasuredSampleCountByLineDuration;


    // Lightweight diagnostics overlay toggled from View -> FPS Meters.
    bool m_showFpsMeters{false};
    QTimer* m_fpsMeterTimer{nullptr};
    QElapsedTimer m_fpsMeterWindow;
    int m_panadapterFrameCount{0};
    int m_waterfallFrameCount{0};
    int m_kiwiSdrWaterfallFrameCount{0};
    double m_panadapterFps{0.0};
    double m_waterfallFps{0.0};
    double m_kiwiSdrWaterfallFps{0.0};
    QLabel* m_panFpsMeterLabel{nullptr};
    QLabel* m_wfFpsMeterLabel{nullptr};
    QLabel* m_syncFpsMeterLabel{nullptr};
    QElapsedTimer m_syncFpsMeterUpdateTimer;
    std::function<QString()> m_fpsMeterSyncStatsProvider;
    qint64 m_lastMouseMoveNs{0};

    // ── TNF markers ────────────────────────────────────────────────────
    QVector<TnfMarker> m_tnfMarkers;
    bool m_tnfGlobalEnabled{true};
    QVector<SpotMarker> m_spotMarkers;
    QVector<SwrSweepPoint> m_swrSweepPoints;
    bool   m_swrSweepRunning{false};
    double m_swrSweepCurrentFreqMhz{-1.0};
    QString m_swrSweepSourceLabel;
    struct SpotHitRect {
        QRect rect;
        double freqMhz;
        int markerIndex;  // index into m_spotMarkers for tooltip data
        QString callsign; // stable hover key (index can go stale on list rebuild)
    };
    QVector<SpotHitRect> m_spotClickRects;
    QString m_hoveredSpotKey;          // callsign@freqKHz, empty when no spot hovered
    bool    m_tooltipRefreshPending{false}; // guards against duplicate queued refreshes
    QRect   m_lastTooltipRect;              // suppresses showText() when hr.rect unchanged

    QVector<SpotCluster> m_spotClusters;
    bool m_showSpots{true};
    bool m_showSHistory{false};
    bool m_showSHistoryQrm{false};
    bool m_sHistorySnapToStep{false};
    bool   m_smartSpotFilter{false};
    qint64 m_smartSpotFilterEnabledMs{0};
    int    m_smartSpotFilterOpacity{80};
    int    m_smartSpotFilterDelayS{30};
    int    m_smartSpotFilterMatchHz{1000};  // ±Hz to count as a spot↔S-History match (#2609)
    QHash<QString, qint64> m_spotConfirmedMs; // key = callsign@freqKHz → last confirmed ms
    QVector<SpotMarker> m_sHistoryMarkers;
    int  m_spotFontSize{16};
    int  m_spotMaxLevels{3};
    int  m_spotStartPct{50};      // % down from top of spectrum
    bool   m_spotOverrideColors{false};
    bool   m_spotOverrideBg{true};
    bool   m_spotShowLines{true};
    QColor m_spotColor{Qt::yellow};
    QColor m_spotBgColor{Qt::black};
    int    m_spotBgOpacity{48};
    int  m_draggingTnfId{-1};
    int  m_hoveredTnfId{-1};
    int    m_dragTnfOrigWidthHz{100};
    double m_dragTnfLastFreq{0.0};
    int    m_dragTnfLastWidthHz{100};
    QPoint m_tnfDragStartPos;
    QLabel* m_tnfHoverPopup{nullptr};

    // Floating overlay menu (child widget, anchored top-left)
    SpectrumOverlayMenu* m_overlayMenu{nullptr};
    // VFO info widgets (one per slice, attached to VFO markers)
    QMap<int, VfoWidget*> m_vfoWidgets;
    VfoWidget* m_vfoWidget{nullptr};  // alias to active slice widget (compat)

    // Bottom-left waterfall buttons: S(egment), B(and), −/+.
    QPushButton* m_kiwiSdrDisplaySourceBtn{nullptr};
    QPushButton* m_zoomSegBtn{nullptr};
    QPushButton* m_zoomBandBtn{nullptr};
    QPushButton* m_zoomOutBtn{nullptr};
    QPushButton* m_zoomInBtn{nullptr};
    bool m_kiwiSdrDisplaySourceKiwi{false};

#ifdef AETHER_GPU_SPECTRUM
    bool m_rhiInitialized{false};

    // Waterfall GPU resources
    QRhiGraphicsPipeline* m_wfPipeline{nullptr};
    QRhiShaderResourceBindings* m_wfSrb{nullptr};
    QRhiBuffer* m_wfVbo{nullptr};
    QRhiBuffer* m_wfUbo{nullptr};
    QRhiTexture* m_wfGpuTex{nullptr};
    QRhiSampler* m_wfSampler{nullptr};
    int m_wfGpuTexW{0};
    int m_wfGpuTexH{0};
    bool m_wfTexFullUpload{true};  // full re-upload needed (resize/init)
    int m_wfLastUploadedRow{-1};   // last row uploaded to GPU (-1 = none)

    // Overlay GPU resources (QPainter → QImage → texture)
    // Static: grid, band plan, scales, slice markers, TNF, spots (repainted on state change)
    // Dynamic: FFT spectrum line (repainted every frame)
    QRhiGraphicsPipeline* m_ovPipeline{nullptr};
    QRhiShaderResourceBindings* m_ovSrb{nullptr};
    QRhiBuffer* m_ovVbo{nullptr};
    QRhiTexture* m_ovGpuTex{nullptr};
    QRhiSampler* m_ovSampler{nullptr};
    QImage m_overlayStatic;     // grid, band plan, scales, markers — drawn ABOVE FFT
    bool m_overlayStaticDirty{true};
    bool m_overlayNeedsUpload{true};

    // Background-image layer — kept separate from m_overlayStatic so it can
    // render BELOW the FFT trace (parity with the software paint path).  Same
    // pipeline + VBO + sampler as m_overlayStatic; we just rebind the SRB
    // between draws so the same overlay shader can paint a different texture.
    QRhiShaderResourceBindings* m_bgSrb{nullptr};
    QRhiTexture* m_bgGpuTex{nullptr};
    QImage m_overlayBg;
    bool m_overlayBgNeedsUpload{true};

    // 3DSS surface layer — the cached 3D image uploaded as a texture and drawn
    // through the overlay pipeline (overlay.frag, premultiplied alpha) as a
    // full-screen quad, above the 2D layers and below the static overlay. Reuses
    // m_ovPipeline / m_ovVbo / m_ovSampler; only the texture + SRB are dedicated.
    QRhiShaderResourceBindings* m_dssSrb{nullptr};
    QRhiTexture* m_dssGpuTex{nullptr};
    bool m_dssTexNeedsUpload{true};
    quint64 m_dssLastUploadedGen{~0ull};  // DssRenderer generation last uploaded
    int m_dssTexW{0};
    int m_dssTexH{0};

    // 3DSS GPU height-map mesh (preferred path). The DssRenderer ring store
    // feeds a ring-buffered R16F height texture; a static perspective grid samples
    // it in dss_mesh.vert. Geometry never rebuilds, so pan/zoom are free. Falls
    // back to the cached-image quad above when the pipeline can't be created.
    QRhiGraphicsPipeline* m_dssMeshFillPipeline{nullptr};  // Triangles, opaque
    QRhiGraphicsPipeline* m_dssMeshLinePipeline{nullptr};  // Lines, alpha (outline)
    QRhiShaderResourceBindings* m_dssMeshSrb{nullptr};
    QRhiBuffer* m_dssMeshVbo{nullptr};       // batched curtain triangles, static
    QRhiBuffer* m_dssMeshLineVbo{nullptr};   // batched ridge line segments, static
    QRhiBuffer* m_dssMeshUbo{nullptr};       // dynamic uniforms
    // std140 UBO float count — must match dss_mesh.{vert,frag}'s U block AND the
    // ubo[] writer in renderGpuFrame(). 8 scalars + texCols + 3 pad + vec4 bgFill.
    static constexpr int kDssMeshUboFloats = 16;
    QRhiTexture* m_dssHeightTex{nullptr};    // R16F ring heightmap (cols x rows)
    QRhiTexture* m_dssPaletteTex{nullptr};   // 256x1 RGBA8 floor->peak LUT
    QRhiSampler* m_dssHeightSampler{nullptr};
    QRhiSampler* m_dssPaletteSampler{nullptr};
    bool m_dssMeshReady{false};
    int  m_dssMeshHeadUploaded{-1};          // ring head last uploaded to heightTex
    quint64 m_dssMeshRowGenUploaded{~0ull};  // DssRenderer rowGeneration uploaded
    quint64 m_dssLutToken{~0ull};            // token of the palette LUT last baked
    QByteArray m_dssRowScratch;              // reused qfloat16 row buffer (mesh upload)
    QByteArray m_dssTextureScratch;          // reused qfloat16 full texture buffer

    void initDssMeshPipeline();
    void uploadDssPaletteLut(QRhiResourceUpdateBatch* batch, float floorDbm, float rangeDb);

    void initWaterfallPipeline();
    void initOverlayPipeline();
    void initSpectrumPipeline();
    void renderGpuFrame(QRhiCommandBuffer* cb);

    // FFT spectrum GPU resources — the trace is evaluated per-pixel by
    // panscope.frag from a width×1 R32F column texture (normalized amplitude
    // per device pixel column), drawn as one full-viewport quad. The CPU per
    // frame only resamples the display trace to device columns and uploads
    // ~4 bytes/column, replacing the old per-frame feather/core/fill vertex
    // bake (~1.4 MB of VBO writes per frame at a 2140 px pan).
    QRhiGraphicsPipeline* m_fftScopePipeline{nullptr};
    QRhiShaderResourceBindings* m_fftScopeSrb{nullptr};
    QRhiBuffer* m_fftScopeUbo{nullptr};
    QRhiTexture* m_fftColTex{nullptr};
    QRhiSampler* m_fftColSampler{nullptr};
    QRhiTexture::Format m_fftColFormat{QRhiTexture::R32F};
    int m_fftColTexW{0};
    QByteArray m_fftColScratch;  // reused per-frame column staging buffer
#endif

    // ── panstats: per-widget frame-cost counters (automation bridge) ─────────
    // Always-on: a handful of integer adds per frame plus one QElapsedTimer
    // read per instrumented section. Snapshot/reset via panstatsSnapshot().
    struct PanStats {
        QElapsedTimer clock;              // wall interval since last reset
        quint64 updateSpectrumCalls{0};   // FFT frames ingested
        quint64 updateSpectrumUs{0};      // smoothing + floor + ingest cost
        quint64 gpuFrames{0};             // renderGpuFrame invocations
        quint64 gpuFrameUs{0};            // whole CPU-side frame prep + encode
        quint64 fftBuildUs{0};            // trace resample + vertex bake
        quint64 fftVboBytes{0};           // vertex bytes uploaded
        quint64 overlayRebuilds{0};       // static+bg QPainter repaints
        quint64 overlayRebuildUs{0};
        quint64 overlayUploadBytes{0};    // static+bg texture bytes uploaded
        quint64 wfUploadBytes{0};         // waterfall texture bytes uploaded
        quint64 nativeWaterfallCalls{0};  // native VITA waterfall updates
        quint64 nativeWaterfallUs{0};
        quint64 nativeWaterfallHiddenCalls{0};
        quint64 kiwiWaterfallCalls{0};    // Kiwi waterfall updates
        quint64 kiwiWaterfallUs{0};
        quint64 kiwiWaterfallHiddenCalls{0};
        quint64 waterfallVisibleRows{0};
        quint64 waterfallVisibleRowUs{0};
        quint64 waterfallHistoryRows{0};
        quint64 waterfallHistoryRowUs{0};
        quint64 dssLiveRows{0};
        quint64 dssLiveUs{0};
        quint64 dssHiddenLiveRows{0};   // hidden-Flex DSS live-ring warming (#4081)
        quint64 dssHistoryRows{0};
        quint64 dssHistoryUs{0};
        quint64 paintEvents{0};           // software-path paints
        quint64 paintUs{0};
        QHash<QByteArray, quint64> dirtyCauses;  // why the overlay rebuilt
        void noteDirty(const char* cause) {
            dirtyCauses[QByteArray(cause ? cause : "other")]++;
        }
        qint64 sinceMs() {
            if (!clock.isValid())
                clock.start();
            return clock.elapsed();
        }
        void reset() {
            *this = PanStats{};
            clock.start();
        }
    } m_panStats;

    // Mark the static overlay for repaint and schedule a frame update.
    // In non-GPU mode this is just update(). `cause` feeds the panstats
    // dirty-cause breakdown — annotate call sites that can fire at frame rate.
    void markOverlayDirty(const char* cause = nullptr) {
#ifdef AETHER_GPU_SPECTRUM
        if (!m_overlayStaticDirty)
            m_panStats.noteDirty(cause);
        m_overlayStaticDirty = true;
#else
        m_panStats.noteDirty(cause);
#endif
        update();
    }

    void reprojectWaterfall(double oldCenterMhz, double oldBandwidthMhz,
                            double newCenterMhz, double newBandwidthMhz);
    bool reprojectSpectrum(double oldCenterMhz, double oldBandwidthMhz,
                           double newCenterMhz, double newBandwidthMhz);
};

} // namespace AetherSDR
