Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/gui/ClientCompApplet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ class ClientCompGrBar : public QWidget {
m_animTimer.setTimerType(Qt::PreciseTimer);
m_animTimer.setInterval(kMeterSmootherIntervalMs);
connect(&m_animTimer, &QTimer::timeout, this, [this]() {
if (!m_smooth.tick(m_animElapsed.restart()))
const bool settled = !m_smooth.tick(m_animElapsed.restart());
if (settled)
m_animTimer.stop();
update();
if (settled || m_smooth.shouldRepaint())
update();
});
}

Expand Down
6 changes: 4 additions & 2 deletions src/gui/ClientCompMeter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ ClientCompMeter::ClientCompMeter(QWidget* parent) : QWidget(parent)
m_animTimer.setTimerType(Qt::PreciseTimer);
m_animTimer.setInterval(kMeterSmootherIntervalMs);
connect(&m_animTimer, &QTimer::timeout, this, [this]() {
if (!m_smooth.tick(m_animElapsed.restart()))
const bool settled = !m_smooth.tick(m_animElapsed.restart());
if (settled)
m_animTimer.stop();
update();
if (settled || m_smooth.shouldRepaint())
update();
});

// Phase 5 PR 3b — repaint when the theme changes so live edits to
Expand Down
6 changes: 4 additions & 2 deletions src/gui/ClientDeEssApplet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ class ClientDeEssGrBar : public QWidget {
m_animTimer.setTimerType(Qt::PreciseTimer);
m_animTimer.setInterval(kMeterSmootherIntervalMs);
connect(&m_animTimer, &QTimer::timeout, this, [this]() {
if (!m_smooth.tick(m_animElapsed.restart()))
const bool settled = !m_smooth.tick(m_animElapsed.restart());
if (settled)
m_animTimer.stop();
update();
if (settled || m_smooth.shouldRepaint())
update();
});
}

Expand Down
6 changes: 4 additions & 2 deletions src/gui/ClientGateApplet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ class ClientGateGrBar : public QWidget {
m_animTimer.setTimerType(Qt::PreciseTimer);
m_animTimer.setInterval(kMeterSmootherIntervalMs);
connect(&m_animTimer, &QTimer::timeout, this, [this]() {
if (!m_smooth.tick(m_animElapsed.restart()))
const bool settled = !m_smooth.tick(m_animElapsed.restart());
if (settled)
m_animTimer.stop();
update();
if (settled || m_smooth.shouldRepaint())
update();
});
}

Expand Down
65 changes: 65 additions & 0 deletions src/gui/DisplaySettings.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#pragma once

#include "core/AppSettings.h"

#include <QJsonDocument>
#include <QJsonObject>
#include <QString>

namespace AetherSDR {

// Persistence helper for display-related UI toggles (#3283 Lean Mode is the
// first; future display-feature toggles like frameless / theme variants land
// here as additional fields).
//
// Stored as a nested JSON blob under AppSettings["Display"], per the
// nested-JSON-per-feature convention (constitution Principle V). The legacy
// flat key "LeanMode" is migrated into this blob on first read so existing
// users keep their behavior.
class DisplaySettings {
public:
static bool leanMode() { return readObj().value("leanMode").toString("False") == "True"; }

static void setLeanMode(bool on)
{
QJsonObject o = readObj();
o["leanMode"] = on ? QStringLiteral("True") : QStringLiteral("False");
write(o);
}

// One-shot migration from the legacy "LeanMode" flat key. Run at app
// startup before any caller reads the new blob. Safe to call repeatedly:
// returns immediately if the new blob already exists.
static void migrateLegacy()
{
auto& s = AppSettings::instance();
if (s.contains("Display")) return;
const bool legacyLean =
s.value("LeanMode", "False").toString() == "True";
QJsonObject o;
o["leanMode"] = legacyLean ? QStringLiteral("True") : QStringLiteral("False");
write(o);
// Leave the legacy flat key in place — harmless after migration, and a
// future cleanup PR can drop it once we're confident no other reader
// still touches it.
}

private:
static QJsonObject readObj()
{
const QString json =
AppSettings::instance().value("Display", QString{}).toString();
if (json.isEmpty()) return {};
return QJsonDocument::fromJson(json.toUtf8()).object();
}
static void write(const QJsonObject& o)
{
auto& s = AppSettings::instance();
s.setValue("Display",
QString::fromUtf8(
QJsonDocument(o).toJson(QJsonDocument::Compact)));
s.save();
}
};

} // namespace AetherSDR
59 changes: 59 additions & 0 deletions src/gui/MainWindow.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "MainWindow.h"

#include "CwDecodeSettings.h"
#include "DisplaySettings.h"
#ifdef HAVE_MQTT
#include "MqttApplet.h"
#include "MqttSettingsDialog.h"
Expand Down Expand Up @@ -29,6 +30,7 @@
#endif
#include "SpectrumOverlayMenu.h"
#include "VfoWidget.h"
#include "MeterSmoother.h" // global lean-mode meter repaint throttle (#3283)
#include "AppletPanel.h"
#include "containers/ContainerManager.h"
#include "RxApplet.h"
Expand Down Expand Up @@ -1473,6 +1475,17 @@ MainWindow::MainWindow(QWidget* parent)
// Audio worker thread (#502) — AudioEngine runs on its own thread so
// audio processing never competes with paintEvent for main thread CPU.
m_audioThread = new QThread(this);
// Lean render mode (#3283): read persisted state early so panadapters
// created during startup seed their Lean button/widget correctly, then
// apply once after construction to cover VFOs + the WAVE applet.
// Persistence is the nested "Display" blob (Principle V); the legacy
// flat "LeanMode" key is migrated into it on first read.
DisplaySettings::migrateLegacy();
m_leanMode = DisplaySettings::leanMode();
if (m_leanMode) {
QTimer::singleShot(0, this, [this]() { applyLeanMode(true); });
}

m_audioThread->setObjectName("AudioEngine");
m_audio = new AudioEngine; // no parent — will be moved to thread
m_audio->setRxBoost(
Expand Down Expand Up @@ -10020,6 +10033,46 @@ void MainWindow::applyDarkTheme()

// ─── Radio/model event handlers ───────────────────────────────────────────────

void MainWindow::applyLeanMode(bool on)
{
m_leanMode = on;

// Panadapters: opaque single layer (no wallpaper / fill) + ~30 Hz cap.
// Also keep every pan's Lean button in sync (the toggle is global).
for (auto* sw : findChildren<SpectrumWidget*>()) {
sw->setLeanMode(on);
if (auto* menu = sw->overlayMenu())
menu->setLeanChecked(on);
}

// VFO panels: opaque, cacheable layer (kills the translucent re-composite).
for (auto* vfo : findChildren<VfoWidget*>())
vfo->setOpaqueMode(on);

// WAVE scope: hidden + feed dropped. Round-trip respects the user's
// pre-Lean choice (if they had the scope hidden before enabling Lean,
// disabling Lean must not silently re-show it).
if (m_appletPanel) {
if (auto* wave = m_appletPanel->waveApplet()) {
if (on) {
m_preLeanWaveActive = wave->isActive();
wave->setActive(false);
} else {
wave->setActive(m_preLeanWaveActive);
}
}
}

// Meters: throttle their animation repaint so they stop dirtying the shared
// backing store every frame (which forces a full-window texture re-upload to
// recomposite with the GPU panadapter — the dominant pooled cost on large/5K
// windows; see #3283). Native-layering the panel was tried and did not
// isolate them under Qt 6.11/macOS, so we cap the repaint rate instead.
MeterSmoother::setLeanThrottle(on);

DisplaySettings::setLeanMode(on);
}

void MainWindow::onConnectionStateChanged(bool connected)
{
if (m_shuttingDown) {
Expand Down Expand Up @@ -12607,6 +12660,12 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet)
}

// ── Per-pan display controls (client-side) ───────────────────────────
// Global lean render mode — every pan's Lean button drives the same
// app-wide toggle; seed this new pan's button + widget with current state.
connect(menu, &SpectrumOverlayMenu::leanModeToggled,
this, &MainWindow::applyLeanMode);
menu->setLeanChecked(m_leanMode);
sw->setLeanMode(m_leanMode);
connect(menu, &SpectrumOverlayMenu::fftFillAlphaChanged,
sw, &SpectrumWidget::setFftFillAlpha);
connect(menu, &SpectrumOverlayMenu::fftFillColorChanged,
Expand Down
10 changes: 10 additions & 0 deletions src/gui/MainWindow.h
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ class MainWindow : public QMainWindow {
#endif

private slots:
// Global lean render mode (#3283): opaque pan + VFO, ~30 Hz repaint cap
// (kLeanFrameMs = 33), WAVE scope off, meters throttled to ~12 Hz per
// instance. Applied across all panes/VFOs, persisted, reversible.
void applyLeanMode(bool on);

// Radio/connection events
void onConnectionStateChanged(bool connected);
void adjustCatPortCounts(bool connected); // called from onConnectionStateChanged
Expand Down Expand Up @@ -673,6 +678,11 @@ private slots:
// Active slice tracking for multi-slice support
int m_activeSliceId{-1};
bool m_splitActive{false};
bool m_leanMode{false}; // global lean render mode state (#3283)
// Pre-lean WaveApplet active state, captured on Lean activation so the
// round-trip restores whatever the user had before instead of silently
// re-showing the scope. Only meaningful while m_leanMode is true.
bool m_preLeanWaveActive{true};
int m_splitRxSliceId{-1};
int m_splitTxSliceId{-1};
int m_pendingMemoryRevealSliceId{-1};
Expand Down
53 changes: 50 additions & 3 deletions src/gui/MeterSmoother.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#pragma once

#include <QtGlobal>
#include <QElapsedTimer>
#include <cmath>

namespace AetherSDR {
Expand Down Expand Up @@ -99,10 +100,56 @@ class MeterSmoother {
void setBallistics(const Ballistics& b) { m_b = b; }
const Ballistics& ballistics() const { return m_b; }

// ── Lean-mode per-widget repaint gate (#3283) ──────────────────────────
// Every meter animates its MeterSmoother at ~120 Hz; each tick repaints,
// and on a window that also hosts the GPU panadapter every repaint forces a
// full-window backing-store→texture re-upload (the dominant pooled cost on
// large/5K displays). Lean mode keeps the smoother integrating at the full
// rate (so ballistics stay smooth) but gates the *repaint* to ~kLeanRepaintHz
// per widget.
//
// The gate is per-instance (each MeterSmoother carries its own clock),
// because every active meter owns its own MeterSmoother. A previous
// shared-static version of this gate underdelivered on its own claim: with
// N meters racing into the gate, the first to tick each ~83 ms window got
// through and the other N−1 were starved that window, so each individual
// meter only saw the green light at roughly (kLeanRepaintHz / N) Hz —
// visibly stuttery on GR bars and S-meter needles. Per-instance gates let
// every meter independently hit the target rate.
//
// Usage at the meter's timer callback:
// const bool settled = !m_smooth.tick(elapsed);
// if (settled) m_timer.stop();
// if (settled || m_smooth.shouldRepaint()) update();
// (Always paint the settled frame so the final value is never dropped.)
static void setLeanThrottle(bool on) { s_leanThrottle = on; }
static bool leanThrottle() { return s_leanThrottle; }
bool shouldRepaint()
{
if (!s_leanThrottle) {
return true;
}
constexpr qint64 kGateMs = 1000 / kLeanRepaintHz;
if (!m_gate.isValid()) {
m_gate.start();
}
const qint64 now = m_gate.elapsed();
if (m_lastMs < 0 || now - m_lastMs >= kGateMs) {
m_lastMs = now;
return true;
}
return false;
}

private:
Ballistics m_b;
float m_display{0.0f};
float m_target{0.0f};
static constexpr int kLeanRepaintHz = 12; // gated meter repaint rate in lean
static inline bool s_leanThrottle = false;

Ballistics m_b;
float m_display{0.0f};
float m_target{0.0f};
QElapsedTimer m_gate;
qint64 m_lastMs{-1};
};

// Recommended driving-timer interval for a MeterSmoother. 8 ms ≈
Expand Down
10 changes: 8 additions & 2 deletions src/gui/SMeterWidget.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "SMeterWidget.h"
#include "MeterSmoother.h" // shared lean-mode repaint gate (#3283)

#include <QPainter>
#include <QPainterPath>
Expand Down Expand Up @@ -203,11 +204,16 @@ void SMeterWidget::animateNeedle()
&& m_peakHoldTimer.elapsed() > m_peakHoldTimeMs
&& m_peakHoldDbm > m_levelDbm + 0.01f;

if (needleAtTarget && !peakHoldAnimating) {
const bool settled = needleAtTarget && !peakHoldAnimating;
if (settled) {
m_needleAnimation.stop();
}

update();
// Gate the needle repaint in lean mode so it stops dirtying the shared
// backing store ~120×/sec (#3283); always paint the settled frame.
if (settled || m_smooth.shouldRepaint()) {
update();
}
}

void SMeterWidget::updatePeakHoldValue()
Expand Down
10 changes: 10 additions & 0 deletions src/gui/SMeterWidget.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#include <QTimer>
#include <QElapsedTimer>

#include "MeterSmoother.h"

namespace AetherSDR {

// Analog S-Meter gauge widget matching the SmartSDR look.
Expand Down Expand Up @@ -129,6 +131,14 @@ public slots:
// Arc geometry: shallow arc spanning ~70° (like SmartSDR)
static constexpr float ARC_START_DEG = 55.0f; // right end (degrees from +X axis)
static constexpr float ARC_END_DEG = 125.0f; // left end

// SMeterWidget runs its own needle animation (see m_needleAnimation /
// m_needleFraction above), but lean-mode repaint throttling is shared
// with every other meter through MeterSmoother::shouldRepaint().
// Holding an instance here only for its per-widget gate keeps every
// meter independently hitting kLeanRepaintHz instead of starving on a
// shared static (#3283).
MeterSmoother m_smooth;
};

} // namespace AetherSDR
25 changes: 25 additions & 0 deletions src/gui/SpectrumOverlayMenu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,14 @@ void SpectrumOverlayMenu::setPanId(const QString& id)
refreshAntennaCombo();
}

void SpectrumOverlayMenu::setLeanChecked(bool on)
{
if (!m_leanBtn || m_leanBtn->isChecked() == on)
return;
QSignalBlocker block(m_leanBtn); // reflect state without re-emitting
m_leanBtn->setChecked(on);
}

void SpectrumOverlayMenu::setRadioModel(RadioModel* model)
{
if (m_radioModel)
Expand Down Expand Up @@ -1339,6 +1347,23 @@ void SpectrumOverlayMenu::buildDisplayPanel()
++row;
}

// ── Lean render mode toggle (#3283) ─────────────────────────────────
// Global low-overhead render mode: opaque panadapter + VFO, capped
// repaint, WAVE scope off, throttled meters. Lives under Display, just
// below the background chooser. Drives the app-wide toggle.
{
m_leanBtn = new QPushButton("Lean Mode");
m_leanBtn->setCheckable(true);
m_leanBtn->setStyleSheet(btnStyle);
m_leanBtn->setToolTip("Lean mode: opaque panadapter + VFO, capped "
"repaint, WAVE scope off, throttled meters. "
"Reduces CPU/GPU load. Persists across restarts.");
connect(m_leanBtn, &QPushButton::toggled, this,
[this](bool on) { emit leanModeToggled(on); });
grid->addWidget(m_leanBtn, row, 0, 1, 4);
++row;
}

// ── Freq Grid Spacing dropdown (#1390) ──────────────────────────────
{
auto* lbl = new QLabel("Grid:");
Expand Down
Loading
Loading