AetherSDR Dialog Patterns
July 30, 2026 · View on GitHub
When you add a new dialog to AetherSDR — or convert an existing modal one to non-modal — there's a canonical pattern that the project's existing dialogs follow. This doc captures it in one place so contributors (human or agent) don't have to reverse-engineer it from 8+ existing dialogs.
Long-term, the PersistentDialog base class issue (#2605)
will collapse this boilerplate into a parent class with bodyWidget(). Until
then, every new dialog needs to wire these pieces by hand.
The four concerns every persistent dialog handles
A persistent dialog in AetherSDR is non-modal, lazy-constructed, geometry-persistent, and frameless-chrome-aware. Skip any of the four and the dialog will misbehave in a predictable way.
| Concern | Why | Symptom if skipped |
|---|---|---|
| Non-modal + lazy-construct | Operator should keep the dialog open while interacting with the radio | Modal dialog blocks tuning |
WA_DeleteOnClose + QPointer slot | Dialog instance ownership; survive close + re-open without leaks | Stack-allocated dialog leaks on each open; or stale pointer crash |
| Geometry persistence (base64) | Restore last-known position on next launch | Dialog opens at default OS position every time |
| Frameless chrome integration | Respect the global FramelessWindow setting | Double-title-bar visual (native + custom), or wrong chrome on toggle |
The canonical pattern
1. Header — derive from QDialog, declare the slot fields
class FooDialog : public QDialog {
Q_OBJECT
public:
explicit FooDialog(...args..., QWidget* parent = nullptr);
void setFramelessMode(bool on);
protected:
void closeEvent(QCloseEvent* event) override;
void moveEvent(QMoveEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
void saveGeometryToSettings();
void restoreGeometryFromSettings();
// ... actual dialog state members ...
QWidget* m_titleBar{nullptr};
QVBoxLayout* m_bodyLayout{nullptr};
bool m_restoringGeometry{false};
};
Forward-declare class QCloseEvent;, class QMoveEvent;, class QResizeEvent;,
class QVBoxLayout; in the header rather than #includeing the Qt headers.
Include the events in the .cpp where they're used.
2. Constructor — embed the frameless title bar, restore geometry
FooDialog::FooDialog(...args..., QWidget* parent)
: QDialog(parent), ...
{
setWindowTitle("Foo");
setMinimumSize(WIDTH, HEIGHT);
setStyleSheet(kDialogStyle);
// Outer layout wraps the frameless title bar + body widget. Body
// widget contains the actual dialog content.
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);
root->setSpacing(9);
m_bodyLayout = root;
outer->addWidget(bodyWidget, 1);
// ... build dialog content inside `root` ...
// Frameless chrome support — must be wired up before first show()
FramelessResizer::install(this);
setFramelessMode(
AppSettings::instance().value("FramelessWindow", "True").toString() == "True");
// Restore geometry from last session (after content built so initial
// size is sensible for first-launch users).
restoreGeometryFromSettings();
}
3. setFramelessMode — toggle the frameless chrome
This is the same shape for every dialog, only the title and key change:
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);
// Restore geometry ONLY when the dialog was already visible (runtime
// toggle). On first open (wasVisible=false), let Qt place normally.
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();
}
The if (wasVisible) setGeometry(geom) guard is critical on macOS — without
it, first-open dialogs land at the top-left corner because the geometry
captured during construction is the pre-show default position. See PR #2580.
4. Geometry persistence — base64 + move/resize/close
AppSettings stores values as text (SQLite TEXT rows since RFC #4603, XML
before that), so binary QByteArray values from saveGeometry() need base64
encoding to round-trip. The project's existing MainWindowGeometry /
MainWindowState use this same pattern.
void FooDialog::saveGeometryToSettings()
{
auto& s = AppSettings::instance();
s.setValue("FooDialogGeometry", saveGeometry().toBase64());
}
void FooDialog::restoreGeometryFromSettings()
{
const QString geomB64 = AppSettings::instance()
.value("FooDialogGeometry").toString();
if (geomB64.isEmpty()) return;
m_restoringGeometry = true;
restoreGeometry(QByteArray::fromBase64(geomB64.toLatin1()));
m_restoringGeometry = false;
}
void FooDialog::closeEvent(QCloseEvent* event)
{
saveGeometryToSettings();
AppSettings::instance().save(); // flush to disk on close
QDialog::closeEvent(event);
}
void FooDialog::moveEvent(QMoveEvent* event)
{
QDialog::moveEvent(event);
if (!m_restoringGeometry) saveGeometryToSettings();
}
void FooDialog::resizeEvent(QResizeEvent* event)
{
QDialog::resizeEvent(event);
if (!m_restoringGeometry) saveGeometryToSettings();
}
Two details worth understanding:
m_restoringGeometryguard.restoreGeometry()triggers move/resize events as Qt repositions the window. Without the guard, those events would immediately callsaveGeometryToSettings()and overwrite the freshly-loaded value with the dialog's current (default) position.- Save on move/resize is in-memory only; close-time save flushes to disk.
AppSettings::setValueupdates the in-memory store;save()writes the XML file. The move/resize saves keep the in-memory store current so a crash or force-quit preserves the last-known position. The close-timesave()is the canonical persistence point.
5. MainWindow wiring — lazy-construct + raise on re-invoke
In MainWindow.cpp::buildMenuBar():
auto* action = menu->addAction("Foo...");
connect(action, &QAction::triggered, this, [this] {
if (!m_fooDialog) {
auto* dlg = new FooDialog(...args..., this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->setFramelessMode(framelessWindowEnabled());
m_fooDialog = dlg;
}
m_fooDialog->show();
m_fooDialog->raise();
m_fooDialog->activateWindow();
});
In MainWindow.h:
class FooDialog; // forward-declare in namespace AetherSDR
...
QPointer<FooDialog> m_fooDialog;
The QPointer slot auto-nulls when WA_DeleteOnClose destroys the dialog.
Next menu click sees m_fooDialog == nullptr and constructs a fresh one
(which then restores geometry from AppSettings).
6. Runtime frameless toggle propagation
In MainWindow.cpp::setFramelessWindow(bool on):
if (m_fooDialog)
m_fooDialog->setFramelessMode(on);
This propagates the View → Use Frameless Windows toggle to already-open
instances of the dialog. There's currently one line per dialog in
setFramelessWindow() — the PersistentDialog refactor in #2605 will
replace these with a single QSet<QPointer<PersistentDialog>> walk.
Existing dialogs that follow this pattern (look at these for reference)
src/gui/NetworkDiagnosticsDialog.{h,cpp}— most complete reference, has all the patternssrc/gui/MemoryDialog.{h,cpp}src/gui/AetherDspDialog.{h,cpp}src/gui/DxClusterDialog.{h,cpp}(SpotHub)src/gui/MultiFlexDialog.{h,cpp}src/gui/MidiMappingDialog.{h,cpp}src/gui/PanLayoutDialog.{h,cpp}src/gui/ProfileManagerDialog.{h,cpp}— most recent example; has the move/resize event saving
Common pitfalls
These are the issues that have hit real PRs in the project's history. Listing them so they don't bite the next contributor.
| Pitfall | Wrong | Right | Surfaced in |
|---|---|---|---|
| AppSettings is a reference, not a pointer | AppSettings::instance()->value(...) | AppSettings::instance().value(...) | #2591 |
| AppSettings serializes XML, binary needs base64 | setValue("Geom", saveGeometry()) | setValue("Geom", saveGeometry().toBase64()) | #2591 |
Include path uses core/ prefix | #include "AppSettings.h" | #include "core/AppSettings.h" | #2591 |
| Restore at construction triggers move/resize | Save unconditionally on every move/resize | Guard with m_restoringGeometry | #2592 |
First-open setGeometry(geom) on macOS captures pre-show position | setGeometry(geom) unconditionally | if (wasVisible) setGeometry(geom) | #2580 |
| QDialog::show() is non-modal by default | setModal(false) + setWindowModality(Qt::NonModal) (both redundant) | omit both | #2592 |
| Frameless chrome must be set up before first show | Skip setFramelessMode(framelessWindowEnabled()) on construct | Call it in the ctor after FramelessResizer::install(this) | #2591, #2592 |
| Runtime toggle needs explicit propagation | Forget to wire into MainWindow::setFramelessWindow() | Add if (m_fooDialog) m_fooDialog->setFramelessMode(on); | #2591 |
Include heavy Qt event headers in .h | #include <QCloseEvent> in the header | Forward-declare in .h, include in .cpp | #2591 |
See also
CLAUDE.md— project-wide AI agent guidelines including settings persistence../architecture/pipelines.md— broader project architecture overview- Issue #2605 —
PersistentDialogbase class proposal that will eliminate this boilerplate