Skip to content

Add PersistentDialog base class to eliminate dialog-conversion boilerplate #2605

Description

@ten9876

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:

  1. Introduce PersistentDialog + the showOrRaisePersistent helper + the m_persistentDialogs set. ~250 LOC, zero behavior change (no existing dialog migrated yet). Tests: smoke-build, no regressions.

  2. Migrate one dialogProfileManagerDialog 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.

  3. 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

  1. A new persistent dialog can be added with under 10 lines of boilerplate in MainWindow::buildMenuBar() plus a : public PersistentDialog constructor invocation.
  2. MainWindow::setFramelessWindow(bool) walks m_persistentDialogs instead of explicit per-dialog branches.
  3. 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.
  4. Build clean on Linux, macOS, Windows.
  5. 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:

  1. 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.

  2. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    GUIUser interfaceenhancementImprovement to existing featuremaintainer-reviewRequires maintainer review before any action is taken

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions