diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 5ff0c263..797fe1dc 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -3464,6 +3464,11 @@ MainWindow::MainWindow(QWidget* parent) // Overlay-menu antenna wiring is now per-pan in wirePanadapter() (#1260). // Antenna list and S-meter are now wired per-widget in onSliceAdded. + // ── Title bar: Pan Follow ──────────────────────────────────────────────── + connect(m_titleBar, &TitleBar::panFollowToggled, + this, &MainWindow::setPanFollow); + if (m_titleBar->isPanFollowChecked()) setPanFollow(true); + // ── Title bar: PC Audio, master volume, headphone volume ──────────────── // The remote_audio_rx stream controls the radio's audio routing: // stream exists → audio to PC; stream removed → audio to radio speakers. @@ -19126,4 +19131,52 @@ void MainWindow::onSpectrumReadyForSHistory(quint32 streamId, const QVectorsetPanFollowChecked(false); + return; + } + + auto centerPan = [this, s]() { + const QString panId = s->panId(); + if (panId.isEmpty()) return; + const double freq = s->frequency(); + auto* pan = m_radioModel.panadapter(panId); + if (pan && qFuzzyCompare(pan->centerMhz(), freq)) return; + const QString freqStr = QString::number(freq, 'f', 6); + if (pan) pan->applyPanStatus({{"center", freqStr}}); + m_radioModel.sendCommand( + QString("display pan set %1 center=%2").arg(panId, freqStr)); + }; + + centerPan(); + m_panFollowConn = connect(s, &SliceModel::frequencyChanged, + this, [centerPan](double) { centerPan(); }); + }; + + attachToSlice0(); + + // Re-attach whenever a new slice 0 appears (reconnect / re-assignment). + m_panFollowSliceConn = connect(&m_radioModel, &RadioModel::sliceAdded, + this, [this, attachToSlice0](SliceModel* s) { + if (s && s->sliceId() == 0) attachToSlice0(); + }); +} + } // namespace AetherSDR diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index b69b0159..e612a0ff 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -929,6 +929,11 @@ private slots: void onFdvMetersChanged(); #endif + // Pan Follow — keeps the panadapter centered on Slice A frequency + QMetaObject::Connection m_panFollowConn; + QMetaObject::Connection m_panFollowSliceConn; + void setPanFollow(bool on); + #if defined(Q_OS_MAC) || defined(HAVE_PIPEWIRE) DaxBridge* m_daxBridge{nullptr}; QString m_savedMicSelection; // restore on stopDax diff --git a/src/gui/TitleBar.cpp b/src/gui/TitleBar.cpp index 61963c2a..0746b493 100644 --- a/src/gui/TitleBar.cpp +++ b/src/gui/TitleBar.cpp @@ -1,5 +1,6 @@ #include "TitleBar.h" #include "GuardedSlider.h" +#include "TitleBarSettings.h" #include "core/AppSettings.h" #include @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -219,6 +221,38 @@ TitleBar::TitleBar(QWidget* parent) // ── PC Audio + Master Vol + HP Vol ────────────────────────────────────── auto& s = AppSettings::instance(); + // Pan Follow toggle — keeps the panadapter centered on Slice A frequency. + // Persistence is the nested "TitleBar" blob (Principle V); the legacy + // flat "PanLockEnabled" key is migrated into it on first read. + TitleBarSettings::migrateLegacy(); + m_panFollowBtn = new QPushButton("Pan Lock"); + m_panFollowBtn->setCheckable(true); + m_panFollowBtn->setChecked(TitleBarSettings::panLockEnabled()); + m_panFollowBtn->setFixedHeight(22); + m_panFollowBtn->setFixedWidth(70); + m_panFollowBtn->setAccessibleName("Pan follow slice"); + m_panFollowBtn->setAccessibleDescription("Keep panadapter centered on Slice A frequency"); + m_panFollowBtn->setToolTip("Pan Lock — keeps the panadapter centered on Slice A frequency (e.g. for Doppler tracking)"); + + auto updatePanFollowStyle = [this]() { + m_panFollowBtn->setStyleSheet(m_panFollowBtn->isChecked() + ? "QPushButton { background: #1e4a8a; color: #b0e8ff; border: 1px solid #4090d0; " + "border-radius: 3px; font-size: 10px; font-weight: bold; }" + "QPushButton:hover { background: #2558a0; }" + : "QPushButton { background: #1a2a3a; color: #607080; border: 1px solid #304050; " + "border-radius: 3px; font-size: 10px; font-weight: bold; }" + "QPushButton:hover { background: #243848; }"); + }; + updatePanFollowStyle(); + + connect(m_panFollowBtn, &QPushButton::toggled, this, [this, updatePanFollowStyle](bool on) { + updatePanFollowStyle(); + TitleBarSettings::setPanLockEnabled(on); + emit panFollowToggled(on); + }); + m_hbox->addWidget(m_panFollowBtn); + m_hbox->addSpacing(4); + // PC Audio toggle m_pcBtn = new QPushButton("PC Audio"); m_pcBtn->setCheckable(true); @@ -459,6 +493,18 @@ bool TitleBar::isDragHandle(QObject* obj) const return obj && obj->property(kTitleDragHandleProperty).toBool(); } +bool TitleBar::isPanFollowChecked() const +{ + return m_panFollowBtn && m_panFollowBtn->isChecked(); +} + +void TitleBar::setPanFollowChecked(bool on) +{ + if (!m_panFollowBtn) return; + QSignalBlocker block(m_panFollowBtn); + m_panFollowBtn->setChecked(on); +} + bool TitleBar::isSystemMoveAreaAt(const QPoint& globalPos) const { const QPoint localPos = mapFromGlobal(globalPos); diff --git a/src/gui/TitleBar.h b/src/gui/TitleBar.h index afb2a923..1dfc3c2a 100644 --- a/src/gui/TitleBar.h +++ b/src/gui/TitleBar.h @@ -51,7 +51,13 @@ class TitleBar : public QWidget { // as caption drag zones while keeping controls interactive. bool isSystemMoveAreaAt(const QPoint& globalPos) const; + // Pan Lock accessors. Out-of-line so the header doesn't need to pull in + // ; defined alongside the button's setup in TitleBar.cpp. + bool isPanFollowChecked() const; + void setPanFollowChecked(bool on); + signals: + void panFollowToggled(bool on); void pcAudioToggled(bool on); void masterVolumeChanged(int pct); void headphoneVolumeChanged(int pct); @@ -87,6 +93,7 @@ class TitleBar : public QWidget { QLabel* m_appNameLabel{nullptr}; QLabel* m_otherTxLabel{nullptr}; QPushButton* m_mfBtn{nullptr}; + QPushButton* m_panFollowBtn{nullptr}; QPushButton* m_pcBtn{nullptr}; QPushButton* m_speakerBtn{nullptr}; QPushButton* m_headphoneBtn{nullptr}; diff --git a/src/gui/TitleBarSettings.h b/src/gui/TitleBarSettings.h new file mode 100644 index 00000000..cb06afe4 --- /dev/null +++ b/src/gui/TitleBarSettings.h @@ -0,0 +1,69 @@ +#pragma once + +#include "core/AppSettings.h" + +#include +#include +#include + +namespace AetherSDR { + +// Persistence helper for title-bar UI toggles (#3408 PanLock is the first; +// future title-bar toggles land here as additional fields). +// +// Stored as a nested JSON blob under AppSettings["TitleBar"], per the +// nested-JSON-per-feature convention (constitution Principle V). The legacy +// flat key "PanLockEnabled" is migrated into this blob on first read so +// existing users keep their behavior. +class TitleBarSettings { +public: + static bool panLockEnabled() + { + return readObj().value("panLockEnabled").toString("False") == "True"; + } + + static void setPanLockEnabled(bool on) + { + QJsonObject o = readObj(); + o["panLockEnabled"] = on ? QStringLiteral("True") : QStringLiteral("False"); + write(o); + } + + // One-shot migration from the legacy "PanLockEnabled" flat key. Run at app + // startup (or TitleBar construction) 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("TitleBar")) return; + const bool legacyPanLock = + s.value("PanLockEnabled", "False").toString() == "True"; + QJsonObject o; + o["panLockEnabled"] = + legacyPanLock ? 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("TitleBar", QString{}).toString(); + if (json.isEmpty()) return {}; + return QJsonDocument::fromJson(json.toUtf8()).object(); + } + static void write(const QJsonObject& o) + { + auto& s = AppSettings::instance(); + s.setValue("TitleBar", + QString::fromUtf8( + QJsonDocument(o).toJson(QJsonDocument::Compact))); + s.save(); + } +}; + +} // namespace AetherSDR