Background
AetherSDR has accumulated 7+ dialogs that all implement the same lazy-construct + non-modal + geometry-persist + frameless-chrome pattern. Each one reinvents the same ~80 lines of boilerplate across MainWindow::buildMenuBar(), MainWindow::setFramelessWindow(), and the dialog class itself.
This issue proposes a PersistentDialog base class + helper that collapses the boilerplate to ~5 lines per dialog, so future dialog conversions and new contributors don't repeat the same mistakes.
The motivation is concrete: PR #2591 (Profile Manager non-modal conversion) required 9 fixes during review — about a third project-convention papercuts, a third project-knowledge boilerplate, and a third genuine integration complexity coordinating with #2580. The 9 fixes weren't the contributor's fault; the pattern just isn't surfaced anywhere a new contributor would find it.
Current state — the repeated boilerplate
Seven dialogs in MainWindow.cpp use nearly identical patterns:
| Dialog |
Pattern present |
Geometry persist |
Frameless chrome |
setFramelessMode |
NetworkDiagnosticsDialog |
✓ |
✓ |
✓ |
✓ |
MemoryDialog |
✓ |
✓ |
✓ |
✓ |
AetherDspDialog |
✓ |
partial |
✓ |
✓ |
DxClusterDialog (SpotHub) |
✓ |
✓ |
✓ |
✓ |
MultiFlexDialog (#2580) |
✓ |
— |
✓ |
✓ |
MidiMappingDialog (#2580) |
✓ |
— |
✓ |
✓ |
PanLayoutDialog (#2580) |
✓ |
— |
✓ |
✓ |
ProfileManagerDialog (#2580 + #2591) |
✓ |
✓ |
✓ |
✓ |
TX Band Settings (inline in MainWindow) |
✓ |
— |
✓ |
uses setDialogFramelessMode |
Each of these has the same shape in buildMenuBar():
auto* action = menu->addAction("Foo...");
connect(action, &QAction::triggered, this, [this] {
if (m_fooDialog) {
m_fooDialog->raise();
m_fooDialog->activateWindow();
return;
}
auto* dlg = new FooDialog(...args..., this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->setFramelessMode(framelessWindowEnabled());
m_fooDialog = dlg;
dlg->show();
});
And the same shape inside the dialog class:
class FooDialog : public QDialog {
public:
FooDialog(...args..., QWidget* parent);
void setFramelessMode(bool on);
protected:
void closeEvent(QCloseEvent* event) override; // saves geometry
private:
void restoreGeometryFromSettings();
QWidget* m_titleBar{nullptr};
QVBoxLayout* m_bodyLayout{nullptr};
// ... actual dialog content members
};
FooDialog::FooDialog(...) {
auto* outer = new QVBoxLayout(this);
outer->setContentsMargins(0, 0, 0, 0);
outer->setSpacing(0);
m_titleBar = new FramelessWindowTitleBar(QStringLiteral("Foo"), this);
outer->addWidget(m_titleBar);
auto* bodyWidget = new QWidget(this);
auto* root = new QVBoxLayout(bodyWidget);
root->setContentsMargins(9, 9, 9, 9);
m_bodyLayout = root;
outer->addWidget(bodyWidget, 1);
// ... actual dialog content goes inside `root` ...
FramelessResizer::install(this);
setFramelessMode(
AppSettings::instance().value("FramelessWindow", "True").toString() == "True");
restoreGeometryFromSettings();
}
void FooDialog::setFramelessMode(bool on) {
const QRect geom = geometry();
const bool wasVisible = isVisible();
Qt::WindowFlags flags = (windowFlags() & ~Qt::WindowType_Mask) | Qt::Dialog;
flags.setFlag(Qt::FramelessWindowHint, on);
setWindowFlags(flags);
if (wasVisible) setGeometry(geom);
if (m_titleBar) m_titleBar->setVisible(on);
if (m_bodyLayout) m_bodyLayout->setContentsMargins(9, on ? 7 : 9, 9, 9);
if (wasVisible) show();
}
void FooDialog::closeEvent(QCloseEvent* event) {
auto& s = AppSettings::instance();
s.setValue("FooDialogGeometry", saveGeometry().toBase64());
s.save();
QDialog::closeEvent(event);
}
void FooDialog::restoreGeometryFromSettings() {
const QString geomB64 = AppSettings::instance()
.value("FooDialogGeometry").toString();
if (!geomB64.isEmpty())
restoreGeometry(QByteArray::fromBase64(geomB64.toLatin1()));
}
Plus MainWindow::setFramelessWindow(bool on) accumulates one explicit if (auto* dlg = qobject_cast<FooDialog*>(m_fooDialog)) dlg->setFramelessMode(on); line per dialog — currently ~10 such lines.
Proposed design
PersistentDialog base class
src/gui/PersistentDialog.{h,cpp} — handles all the chrome + geometry + frameless toggle. Subclasses fill in their content via the bodyWidget() accessor.
class PersistentDialog : public QDialog {
Q_OBJECT
public:
// title: Window title (also displayed in the frameless chrome).
// geomKey: AppSettings key for geometry persistence. If empty, no persist.
explicit PersistentDialog(const QString& title,
const QString& geomKey,
QWidget* parent = nullptr);
void setFramelessMode(bool on);
QWidget* bodyWidget() const { return m_body; }
protected:
void closeEvent(QCloseEvent* event) override; // auto-saves geometry
private:
void restoreGeometryFromSettings();
QString m_geomKey;
FramelessWindowTitleBar* m_titleBar{nullptr};
QWidget* m_body{nullptr};
QVBoxLayout* m_bodyLayout{nullptr};
};
A subclass becomes:
class ProfileManagerDialog : public PersistentDialog {
Q_OBJECT
public:
explicit ProfileManagerDialog(RadioModel* model, QWidget* parent = nullptr)
: PersistentDialog("Profile Manager", "ProfileManagerDialogGeometry", parent),
m_model(model)
{
auto* root = new QVBoxLayout(bodyWidget());
// ... existing dialog content unchanged ...
}
private:
// ... only the actual dialog state, no chrome/geometry plumbing ...
};
MainWindow::showOrRaisePersistent<T>(slot, ...) helper
template <class T, class... Args>
void MainWindow::showOrRaisePersistent(QPointer<T>& slot, Args&&... ctorArgs) {
if (!slot) {
slot = new T(std::forward<Args>(ctorArgs)..., this);
slot->setAttribute(Qt::WA_DeleteOnClose);
slot->setFramelessMode(framelessWindowEnabled());
m_persistentDialogs.insert(slot); // tracked for setFramelessWindow()
}
slot->show();
slot->raise();
slot->activateWindow();
}
The menu wiring becomes one line:
connect(profileMgrAct, &QAction::triggered, this, [this] {
showOrRaisePersistent(m_profileManagerDialog, &m_radioModel);
});
MainWindow::setFramelessWindow(bool on) simplification
The current 10+ explicit propagation lines collapse to:
for (PersistentDialog* dlg : std::as_const(m_persistentDialogs))
if (dlg) dlg->setFramelessMode(on);
With m_persistentDialogs declared as QSet<QPointer<PersistentDialog>> m_persistentDialogs; (QPointer entries auto-null on dialog destruction; QSet skip-on-null in the loop).
Migration plan
Land in three steps so the change is reviewable:
-
Introduce PersistentDialog + the showOrRaisePersistent helper + the m_persistentDialogs set. ~250 LOC, zero behavior change (no existing dialog migrated yet). Tests: smoke-build, no regressions.
-
Migrate one dialog — ProfileManagerDialog is the natural pilot since it's the most recently touched. Diff: -80 LOC, +5 LOC. The existing setFramelessWindow propagation line for it gets removed. Tests: verify geometry persists across runs, frameless toggle still propagates correctly, non-modal show/raise/activate works.
-
Migrate the remaining 6 dialogs one PR at a time (or one bulk PR if the diff stays clean). Each migration is mechanical: derive from PersistentDialog, move content into bodyWidget(), delete the dialog's own setFramelessMode/closeEvent/geometry plumbing, drop the explicit propagation line from setFramelessWindow().
Acceptance
- A new persistent dialog can be added with under 10 lines of boilerplate in
MainWindow::buildMenuBar() plus a : public PersistentDialog constructor invocation.
MainWindow::setFramelessWindow(bool) walks m_persistentDialogs instead of explicit per-dialog branches.
- All migrated dialogs preserve their existing behavior: frameless chrome correct, geometry persists across runs, runtime Frameless Window toggle propagates, non-modal show/raise/activate works.
- Build clean on Linux, macOS, Windows.
- No regressions in any of the visible UX patterns the migrated dialogs already exhibited (size grip behavior, title bar drag, double-click maximize, 8-axis resize).
Out of scope
- The inline
TX Band Settings dialog in MainWindow::buildMenuBar() — this one is built ad-hoc and would need to be promoted to its own class to participate. Optional follow-up.
- The
Reconnect dialog — uses setDialogFramelessMode helper rather than a per-dialog method. Could be migrated but would change its construction pattern more invasively.
AetherialAudioStrip — uses the frameless pattern but is not a QDialog. Out of scope for PersistentDialog (which is dialog-specific); might benefit from its own base class later.
Why this matters
Two reasons:
-
First-time contributor friction. New contributors can't realistically discover the project's dialog conventions without reading 5 existing dialogs. The boilerplate is the right answer in 7 places — it should be the only answer, expressed once.
-
Future dialog conversions. Several modal dialogs in the menu could benefit from non-modal conversion. Each one is currently a ~80-line PR with 9 review fixes; with this infrastructure, it's a 15-line PR with maybe 1 fix.
Context
Surfaced during review of PR #2591 (Profile Manager non-modal conversion). The contributor's PR was good in shape but accumulated 9 fixes during review for project conventions + integration coupling that aren't documented anywhere. Rather than continue to land near-identical fixes one PR at a time, build the infrastructure that eliminates the boilerplate class.
Related PRs that established the current pattern:
73, Jeremy KK7GWY & Claude (AI dev partner)
Background
AetherSDR has accumulated 7+ dialogs that all implement the same lazy-construct + non-modal + geometry-persist + frameless-chrome pattern. Each one reinvents the same ~80 lines of boilerplate across
MainWindow::buildMenuBar(),MainWindow::setFramelessWindow(), and the dialog class itself.This issue proposes a
PersistentDialogbase class + helper that collapses the boilerplate to ~5 lines per dialog, so future dialog conversions and new contributors don't repeat the same mistakes.The motivation is concrete: PR #2591 (Profile Manager non-modal conversion) required 9 fixes during review — about a third project-convention papercuts, a third project-knowledge boilerplate, and a third genuine integration complexity coordinating with #2580. The 9 fixes weren't the contributor's fault; the pattern just isn't surfaced anywhere a new contributor would find it.
Current state — the repeated boilerplate
Seven dialogs in
MainWindow.cppuse nearly identical patterns:setFramelessModeNetworkDiagnosticsDialogMemoryDialogAetherDspDialogDxClusterDialog(SpotHub)MultiFlexDialog(#2580)MidiMappingDialog(#2580)PanLayoutDialog(#2580)ProfileManagerDialog(#2580 + #2591)TX Band Settings(inline in MainWindow)setDialogFramelessModeEach of these has the same shape in
buildMenuBar():And the same shape inside the dialog class:
Plus
MainWindow::setFramelessWindow(bool on)accumulates one explicitif (auto* dlg = qobject_cast<FooDialog*>(m_fooDialog)) dlg->setFramelessMode(on);line per dialog — currently ~10 such lines.Proposed design
PersistentDialogbase classsrc/gui/PersistentDialog.{h,cpp}— handles all the chrome + geometry + frameless toggle. Subclasses fill in their content via thebodyWidget()accessor.A subclass becomes:
MainWindow::showOrRaisePersistent<T>(slot, ...)helperThe menu wiring becomes one line:
MainWindow::setFramelessWindow(bool on)simplificationThe current 10+ explicit propagation lines collapse to:
With
m_persistentDialogsdeclared asQSet<QPointer<PersistentDialog>> m_persistentDialogs;(QPointer entries auto-null on dialog destruction; QSet skip-on-null in the loop).Migration plan
Land in three steps so the change is reviewable:
Introduce
PersistentDialog+ theshowOrRaisePersistenthelper + them_persistentDialogsset. ~250 LOC, zero behavior change (no existing dialog migrated yet). Tests: smoke-build, no regressions.Migrate one dialog —
ProfileManagerDialogis the natural pilot since it's the most recently touched. Diff: -80 LOC, +5 LOC. The existingsetFramelessWindowpropagation line for it gets removed. Tests: verify geometry persists across runs, frameless toggle still propagates correctly, non-modal show/raise/activate works.Migrate the remaining 6 dialogs one PR at a time (or one bulk PR if the diff stays clean). Each migration is mechanical: derive from
PersistentDialog, move content intobodyWidget(), delete the dialog's ownsetFramelessMode/closeEvent/geometry plumbing, drop the explicit propagation line fromsetFramelessWindow().Acceptance
MainWindow::buildMenuBar()plus a: public PersistentDialogconstructor invocation.MainWindow::setFramelessWindow(bool)walksm_persistentDialogsinstead of explicit per-dialog branches.Out of scope
TX Band Settingsdialog inMainWindow::buildMenuBar()— this one is built ad-hoc and would need to be promoted to its own class to participate. Optional follow-up.Reconnect dialog— usessetDialogFramelessModehelper rather than a per-dialog method. Could be migrated but would change its construction pattern more invasively.AetherialAudioStrip— uses the frameless pattern but is not aQDialog. Out of scope forPersistentDialog(which is dialog-specific); might benefit from its own base class later.Why this matters
Two reasons:
First-time contributor friction. New contributors can't realistically discover the project's dialog conventions without reading 5 existing dialogs. The boilerplate is the right answer in 7 places — it should be the only answer, expressed once.
Future dialog conversions. Several modal dialogs in the menu could benefit from non-modal conversion. Each one is currently a ~80-line PR with 9 review fixes; with this infrastructure, it's a 15-line PR with maybe 1 fix.
Context
Surfaced during review of PR #2591 (Profile Manager non-modal conversion). The contributor's PR was good in shape but accumulated 9 fixes during review for project conventions + integration coupling that aren't documented anywhere. Rather than continue to land near-identical fixes one PR at a time, build the infrastructure that eliminates the boilerplate class.
Related PRs that established the current pattern:
FramelessWindowTitleBarfor applet popouts)FramelessMoveHelper)setFramelessModeto 5 popout dialogs73, Jeremy KK7GWY & Claude (AI dev partner)