diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 120453f..1e11b4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,8 +71,11 @@ green locally. They are worth knowing before you spend a round trip on one: releases format differently, and a newer analyser reports different findings; both make a formatting job fail on code your editor just formatted. * **CI builds against the minimum supported Qt**, not the newest. Code that compiles - against a newer Qt locally can still fail there — Qt's own macros differ between - versions. + against a newer Qt locally can still fail there — Qt's own macros and overload sets + differ between versions. A concrete one: the tree is built with + `QT_NO_CAST_FROM_ASCII`, and `QString::arg("text")` compiles against Qt 6.10 but not + against the 6.8 minimum. Wrap every string literal handed to a Qt API in + `QStringLiteral` or `tr()`. * **libstdc++ and libc++ disagree about which headers include which.** A translation unit that compiles on macOS can fail on Linux for a missing ``. Include what you use. diff --git a/README.md b/README.md index bd2be05..4e2eb48 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ evaluates traces against CISPR/FCC limit lines and produces reproducible reports | | | |---|---| -| Status | Early development — the measurement suite, its instrument buses, reporting and the plugin bridge are in place (milestones M0–M7) | +| Status | Early development: the measurement suite, its instrument buses, reporting and the plugin bridge are in place (milestones M0–M7) | | Language | C++23, Qt 6.8+ (developed on 6.10) | | Platforms | Windows 10/11, Linux (Ubuntu 22.04+), macOS 13+ | | Docs | [Contributing](CONTRIBUTING.md) · [Requirements](docs/requirements.md) · [Architecture](docs/architecture.md) · [Plugin API](docs/plugin-api.md) · [Tasklist](tasklist.md) | @@ -32,7 +32,7 @@ evaluates traces against CISPR/FCC limit lines and produces reproducible reports instrument cannot make is refused with a reason rather than sent and rejected. * **Nothing is hidden.** Every SCPI exchange is visible in the console and in the `--verbose` transcript. Limit lines and correction tables are documented CSV/JSON you can - write by hand, and sessions are versioned JSON — your measurements stay diffable, + write by hand, and sessions are versioned JSON, so your measurements stay diffable, scriptable and readable without PeakEmi. * **Nothing to plug in to start.** The simulated analyzer ships with the application, so the full loop, including the report, can be tried before any hardware is on the bench. @@ -41,7 +41,7 @@ evaluates traces against CISPR/FCC limit lines and produces reproducible reports * **No licence server, no dongle, no seat count.** GPL-3.0-or-later; build it yourself and run it offline. -The instrument list is short and the scope is deliberately narrow — pre-compliance +The instrument list is short and the scope is deliberately narrow: pre-compliance measurement, not lab automation. Turntables, masts and LISNs are switched through the [commands sent around a run](#what-works-today), not driven by PeakEmi. @@ -58,11 +58,16 @@ verified Phase 2 points. Traces are decimated to a min/max envelope per pixel co ![Spectrum plot with limit overlay and flagged peaks](docs/images/spectrum-plot.png) -Every verified point lands in the results table with its full provenance — detector, resolution -bandwidth, dwell time, limit, margin and timestamp — coloured by verdict. +Every verified point lands in the results table with its full provenance (detector, resolution +bandwidth, dwell time, limit, margin and timestamp), coloured by verdict. ![Phase 2 result table](docs/images/results-table.png) +The interface follows the desktop colour scheme, and the verdict colours are chosen per scheme +so the table stays readable in either: + +![Phase 2 result table in dark mode](docs/images/results-table-dark.png) + The log dock keeps the run narrative and the raw SCPI transcript side by side, so what the application asked the instrument is always inspectable. @@ -75,44 +80,45 @@ so they cannot drift from the application: cmake --preset debug -DPEAKEMI_BUILD_TOOLS=ON cmake --build --preset debug --target peakemi_screenshots QT_QPA_PLATFORM=offscreen ./build/debug/bin/peakemi_screenshots docs/images +QT_QPA_PLATFORM=offscreen ./build/debug/bin/peakemi_screenshots docs/images --dark ``` ## What works today -* **Two-phase automated run** — fast peak scan, peak flagging against the active limit +* **Two-phase automated run**: fast peak scan, peak flagging against the active limit lines, then a quasi-peak dwell on every flagged peak with the CISPR-mandated RBW. Pausable, resumable, abortable, with autosave after every verified point. -* **Simulated analyzer** — ships with the app, needs no hardware, and produces a +* **Simulated analyzer**: ships with the app, needs no hardware, and produces a deterministic spectrum that fails CISPR 32 class B in a few places, so a new user can go from launch to a finished run and a PDF report in a minute. -* **Instruments** — raw SCPI over TCP, VXI-11, USBTMC and serial, `*IDN?`-scored driver +* **Instruments**: raw SCPI over TCP, VXI-11, USBTMC and serial, `*IDN?`-scored driver selection, bounded and opt-in LAN sweep, live USB hotplug detection, serial port enumeration, an optional VISA path, and a raw SCPI console. -* **Supported analyzers** — Siglent SSA3021X/3032X/3075X and SVA1015X/1032X/1075X, Rigol +* **Supported analyzers**: Siglent SSA3021X/3032X/3075X and SVA1015X/1032X/1075X, Rigol DSA705/710 and DSA815/832/875, each with its own frequency range, point count and command dialect. -* **Python driver plugins** — in a build configured with `PEAKEMI_WITH_PYTHON=ON`, a +* **Python driver plugins**: in a build configured with `PEAKEMI_WITH_PYTHON=ON`, a driver can be a single Python file loaded into the embedded interpreter, and the measurement engine cannot tell it from one written in C++. A plugin is imported only after you approve it, and the approval is recorded as a hash of the file's contents, so an edited plugin is untrusted again until you say otherwise. See the [plugin API](docs/plugin-api.md) and the worked [example driver](plugins/drivers/example_sweeper.py). -* **Commands around a run** — a run can send operator-supplied commands when it starts and +* **Commands around a run**: a run can send operator-supplied commands when it starts and when it ends, which is how a LISN, a relay box or a mast that speaks SCPI is switched. The closing commands are sent whether the run finished, was aborted or failed. PeakEmi drives no relays itself. -* **Limits and corrections** — built-in CISPR 32 / EN 55032 and FCC Part 15 B catalogue, +* **Limits and corrections**: built-in CISPR 32 / EN 55032 and FCC Part 15 B catalogue, CSV/JSON import of custom limits and of antenna/cable/gain correction tables (see [`resources/`](resources) for the documented file formats). -* **Sessions and exports** — versioned JSON session container written atomically, CSV and +* **Sessions and exports**: versioned JSON session container written atomically, CSV and JSON export of traces and results, and a PDF report carrying the mandatory pre-compliance disclaimer. Company, address, logo and the free-text sections come from a report template that can be edited in the app, shared as a file and kept as the default. What is left is packaging: a notarised macOS disk image, signed Windows artifacts and a -Windows installer — see [tasklist.md](tasklist.md). +Windows installer, see [tasklist.md](tasklist.md). ### Instrument buses @@ -171,6 +177,6 @@ any hardware attached. Logs are written to the platform application data directo ## Licence -GPL-3.0-or-later — the full text is in [LICENSE](LICENSE). The plugin API headers are +GPL-3.0-or-later, the full text is in [LICENSE](LICENSE). The plugin API headers are dual-licensed so that proprietary in-house drivers remain possible; see [requirements.md §1.4](docs/requirements.md#14-technology-stack-con-3). diff --git a/docs/images/about-dialog-dark.png b/docs/images/about-dialog-dark.png new file mode 100644 index 0000000..7d2bf97 Binary files /dev/null and b/docs/images/about-dialog-dark.png differ diff --git a/docs/images/about-dialog.png b/docs/images/about-dialog.png new file mode 100644 index 0000000..17168d7 Binary files /dev/null and b/docs/images/about-dialog.png differ diff --git a/docs/images/log-console-dark.png b/docs/images/log-console-dark.png new file mode 100644 index 0000000..5964f40 Binary files /dev/null and b/docs/images/log-console-dark.png differ diff --git a/docs/images/log-console.png b/docs/images/log-console.png index b0f2460..268447b 100644 Binary files a/docs/images/log-console.png and b/docs/images/log-console.png differ diff --git a/docs/images/main-window-dark.png b/docs/images/main-window-dark.png new file mode 100644 index 0000000..ad907b8 Binary files /dev/null and b/docs/images/main-window-dark.png differ diff --git a/docs/images/main-window.png b/docs/images/main-window.png index c5d198d..9e07d1c 100644 Binary files a/docs/images/main-window.png and b/docs/images/main-window.png differ diff --git a/docs/images/results-table-dark.png b/docs/images/results-table-dark.png new file mode 100644 index 0000000..d3d1145 Binary files /dev/null and b/docs/images/results-table-dark.png differ diff --git a/docs/images/results-table.png b/docs/images/results-table.png index 1ab7803..2b9aa97 100644 Binary files a/docs/images/results-table.png and b/docs/images/results-table.png differ diff --git a/docs/images/spectrum-plot-dark.png b/docs/images/spectrum-plot-dark.png new file mode 100644 index 0000000..617dfdb Binary files /dev/null and b/docs/images/spectrum-plot-dark.png differ diff --git a/docs/images/spectrum-plot.png b/docs/images/spectrum-plot.png index 4cd0091..79c866e 100644 Binary files a/docs/images/spectrum-plot.png and b/docs/images/spectrum-plot.png differ diff --git a/resources/peakemi.qrc b/resources/peakemi.qrc new file mode 100644 index 0000000..e1ad9c5 --- /dev/null +++ b/resources/peakemi.qrc @@ -0,0 +1,6 @@ + + + + peakemi.png + + diff --git a/src/app/main.cpp b/src/app/main.cpp index 6c083a4..4b72a9e 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -63,6 +64,11 @@ int main(int argc, char* argv[]) peakemi::registerMetaTypes(); peakemi::drivers::registerBuiltInDrivers(); + // Compiled in rather than loaded from disk, so the icon is there on every + // platform and in every packaging of the application. AboutDialog.cpp pulls + // the resource into the link. + QApplication::setWindowIcon(QIcon{QStringLiteral(":/peakemi/icon.png")}); + peakemi::ui::MainWindow window; window.show(); diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index b4eacc4..f0f1f91 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -1,6 +1,9 @@ peakemi_add_module(ui QT_AUTOMOC + QT_AUTORCC SOURCES + "${PROJECT_SOURCE_DIR}/resources/peakemi.qrc" + dialogs/AboutDialog.cpp dialogs/PluginManagerDialog.cpp dialogs/ReportTemplateDialog.cpp docks/InstrumentDock.cpp @@ -12,6 +15,7 @@ peakemi_add_module(ui plot/PainterPlotBackend.cpp windows/MainWindow.cpp HEADERS + include/peakemi/ui/AboutDialog.h include/peakemi/ui/IPlotBackend.h include/peakemi/ui/InstrumentDock.h include/peakemi/ui/LogDock.h diff --git a/src/ui/dialogs/AboutDialog.cpp b/src/ui/dialogs/AboutDialog.cpp new file mode 100644 index 0000000..9a1255d --- /dev/null +++ b/src/ui/dialogs/AboutDialog.cpp @@ -0,0 +1,151 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/// Registers the compiled-in resources. +/// +/// At global scope on purpose, and not in an anonymous namespace: +/// Q_INIT_RESOURCE declares the generated symbol in whatever namespace it is +/// expanded in, while the generated one lives in none. The call is needed at +/// all because the resource is compiled into a static library, from which the +/// linker drops anything that nothing references. +// NOLINTNEXTLINE(misc-use-anonymous-namespace) +static void initPeakEmiResources() +{ + Q_INIT_RESOURCE(peakemi); +} + +namespace peakemi::ui { +namespace { + +constexpr int IconSize = 96; +constexpr int DialogWidth = 560; +constexpr int NoticeMargin = 10; + +/// A link colour that stays readable on the background it is drawn on. +/// +/// Several desktop dark themes leave QPalette::Link at its light-theme navy, +/// which all but disappears on a dark window, so the dialog picks its own. +[[nodiscard]] QColor linkColour(const QPalette& palette) +{ + const bool dark = palette.color(QPalette::Window).lightness() < 128; + return dark ? QColor{0x7F, 0xB6, 0xFF} : QColor{0x11, 0x4E, 0xA8}; +} + +/// Lets the layout ask a wrapped label how tall it needs to be at a given +/// width. Without this the dialog is sized from the label's first guess and +/// the last line of a paragraph ends up cut off. +void wrapAndGrow(QLabel* label) +{ + label->setWordWrap(true); + QSizePolicy policy = label->sizePolicy(); + policy.setHeightForWidth(true); + label->setSizePolicy(policy); +} + +[[nodiscard]] QString qs(std::string_view text) +{ + return QString::fromUtf8(text.data(), static_cast(text.size())); +} + +} // namespace + +QString AboutDialog::repositoryUrl() +{ + return QStringLiteral("https://github.com/bitcrushtesting/peakemi"); +} + +QString AboutDialog::licenceUrl() +{ + return repositoryUrl() + QStringLiteral("/blob/main/LICENSE"); +} + +AboutDialog::AboutDialog(QWidget* parent) : QDialog{parent} +{ + initPeakEmiResources(); + + setWindowTitle(tr("About PeakEmi")); + setObjectName(QStringLiteral("aboutDialog")); + + QPalette scheme = palette(); + scheme.setColor(QPalette::Link, linkColour(scheme)); + scheme.setColor(QPalette::LinkVisited, linkColour(scheme)); + setPalette(scheme); + + auto* layout = new QVBoxLayout{this}; + + auto* header = new QHBoxLayout; + header->setSpacing(16); + auto* icon = new QLabel{this}; + icon->setObjectName(QStringLiteral("aboutIcon")); + icon->setPixmap(QIcon{QStringLiteral(":/peakemi/icon.png")}.pixmap(IconSize, IconSize)); + icon->setFixedSize(IconSize, IconSize); + header->addWidget(icon); + + auto* title = new QLabel{this}; + title->setObjectName(QStringLiteral("aboutTitle")); + title->setTextFormat(Qt::RichText); + title->setText(tr("

PeakEmi %1

" + "

EMI pre-compliance measurement suite
" + "Developed by Bitcrush Testing

") + .arg(qs(ProjectVersion))); + header->addWidget(title, 1); + layout->addLayout(header); + + auto* details = new QLabel{this}; + details->setObjectName(QStringLiteral("aboutDetails")); + details->setTextFormat(Qt::RichText); + wrapAndGrow(details); + // Links open in the user's browser rather than inside a measurement tool. + details->setOpenExternalLinks(true); + details->setTextInteractionFlags(Qt::TextBrowserInteraction); + details->setText(tr("

Licence: GPL-3.0-or-later — " + "read the licence.
" + "PeakEmi is free software: you may use, study, share and modify it " + "under the terms of the GNU General Public License, version 3 or " + "any later version. It comes with no warranty.

" + "

Source: %2

") + .arg(licenceUrl(), repositoryUrl())); + layout->addWidget(details); + + // The notice reads as a notice: framed, and never quieter than the rest. + // The frame is a widget of its own rather than QLabel::setFrameShape so + // that its layout accounts for the border when the text wraps. + auto* notice = new QFrame{this}; + notice->setFrameShape(QFrame::StyledPanel); + auto* noticeLayout = new QVBoxLayout{notice}; + noticeLayout->setContentsMargins(NoticeMargin, NoticeMargin, NoticeMargin, NoticeMargin); + + auto* disclaimer = new QLabel{notice}; + disclaimer->setObjectName(QStringLiteral("aboutDisclaimer")); + wrapAndGrow(disclaimer); + disclaimer->setText(qs(ComplianceDisclaimer)); + noticeLayout->addWidget(disclaimer); + layout->addWidget(notice); + + auto* buttons = new QDialogButtonBox{QDialogButtonBox::Close, this}; + auto* aboutQt = buttons->addButton(tr("About Qt"), QDialogButtonBox::ActionRole); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::accept); + connect(aboutQt, &QPushButton::clicked, qApp, &QApplication::aboutQt); + layout->addWidget(buttons); + + // Fix the width first: a wrapped paragraph only knows how tall it is once + // it knows how wide it is, so the height has to be asked for at that width. + setMinimumWidth(DialogWidth); + layout->activate(); + resize(DialogWidth, layout->totalHeightForWidth(DialogWidth)); +} + +} // namespace peakemi::ui diff --git a/src/ui/include/peakemi/ui/AboutDialog.h b/src/ui/include/peakemi/ui/AboutDialog.h new file mode 100644 index 0000000..0411703 --- /dev/null +++ b/src/ui/include/peakemi/ui/AboutDialog.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace peakemi::ui { + +/// What PeakEmi is, who made it, what it may be used for and where to get it. +/// +/// A dialog of its own rather than QMessageBox::about, because the links have +/// to be clickable: a licence a user cannot open is not much of a licence +/// notice, and a repository nobody can reach is not much of a source offer. +class AboutDialog : public QDialog +{ + Q_OBJECT + +public: + explicit AboutDialog(QWidget* parent = nullptr); + + /// Where the source lives. Also the source offer the GPL expects to + /// accompany a distributed binary. + [[nodiscard]] static QString repositoryUrl(); + [[nodiscard]] static QString licenceUrl(); +}; + +} // namespace peakemi::ui diff --git a/src/ui/include/peakemi/ui/ResultsTableModel.h b/src/ui/include/peakemi/ui/ResultsTableModel.h index 6de7e56..c01653d 100644 --- a/src/ui/include/peakemi/ui/ResultsTableModel.h +++ b/src/ui/include/peakemi/ui/ResultsTableModel.h @@ -11,8 +11,10 @@ namespace peakemi::ui { /// Table model over the Phase 2 results (FR-VIS-4, FR-DAT-1). /// -/// Verdict colouring lives in the model's BackgroundRole so the view stays a -/// plain QTableView and sorting/filtering works through a proxy. +/// Verdict colouring lives in the model's Background and Foreground roles so +/// the view stays a plain QTableView and sorting/filtering works through a +/// proxy. Both colours are picked for the active colour scheme: a background +/// chosen for a light theme leaves the text unreadable on a dark one. class ResultsTableModel : public QAbstractTableModel { Q_OBJECT @@ -54,7 +56,14 @@ class ResultsTableModel : public QAbstractTableModel [[nodiscard]] const MeasurementPoint* pointAt(int row) const; +protected: + /// Watches the application for theme changes; see refreshColours(). + bool eventFilter(QObject* watched, QEvent* event) override; + private: + /// Repaint the verdict colours after a theme change. + void refreshColours(); + std::vector m_points; }; diff --git a/src/ui/models/ResultsTableModel.cpp b/src/ui/models/ResultsTableModel.cpp index ee14db7..e5c0174 100644 --- a/src/ui/models/ResultsTableModel.cpp +++ b/src/ui/models/ResultsTableModel.cpp @@ -3,6 +3,10 @@ #include #include +#include +#include +#include +#include #include #include @@ -44,18 +48,47 @@ namespace { return {}; } -[[nodiscard]] QColor verdictColour(Verdict verdict) +/// Background and text for one verdict. +/// +/// Both are stated, and both depend on the colour scheme. Setting only the +/// background is what made the table unreadable in dark mode: the pale tints +/// stayed while the theme's text turned near-white, leaving pale text on a pale +/// row. A verdict has to be readable at a glance -- it is the column an +/// operator scans -- so neither colour is left to chance. +struct VerdictColours { + QColor background; + QColor text; +}; + +/// Whether rows are painted on a dark background. +/// +/// The palette is the thing that gets painted, so it is the thing to ask. The +/// colour-scheme hint answers a related but different question -- what the +/// system prefers -- and says nothing about an application palette set by a +/// stylesheet, by a command line switch, or by a screenshot tool. +[[nodiscard]] bool isDarkScheme() +{ + return QGuiApplication::palette().color(QPalette::Base).lightness() < 128; +} + +[[nodiscard]] VerdictColours verdictColours(Verdict verdict) +{ + const bool dark = isDarkScheme(); switch (verdict) { case Verdict::Pass: - return QColor{0xE6, 0xF4, 0xEA}; + return dark ? VerdictColours{QColor{0x15, 0x30, 0x1E}, QColor{0xA8, 0xE6, 0xBC}} + : VerdictColours{QColor{0xE6, 0xF4, 0xEA}, QColor{0x0B, 0x40, 0x1E}}; case Verdict::Marginal: - return QColor{0xFE, 0xF7, 0xE0}; + return dark ? VerdictColours{QColor{0x3A, 0x30, 0x0E}, QColor{0xF7, 0xD9, 0x7A}} + : VerdictColours{QColor{0xFE, 0xF7, 0xE0}, QColor{0x5F, 0x45, 0x00}}; case Verdict::Fail: - return QColor{0xFC, 0xE8, 0xE6}; + return dark ? VerdictColours{QColor{0x40, 0x1A, 0x18}, QColor{0xFF, 0xA9, 0xA0}} + : VerdictColours{QColor{0xFC, 0xE8, 0xE6}, QColor{0x7A, 0x1A, 0x12}}; case Verdict::Unknown: break; } + // No verdict: the row keeps the theme's own colours. return {}; } @@ -66,7 +99,36 @@ namespace { } // namespace -ResultsTableModel::ResultsTableModel(QObject* parent) : QAbstractTableModel{parent} {} +ResultsTableModel::ResultsTableModel(QObject* parent) : QAbstractTableModel{parent} +{ + // A table already on screen has to be repainted when the theme changes, + // rather than waiting for the next measurement to redraw it. + connect(QGuiApplication::styleHints(), + &QStyleHints::colorSchemeChanged, + this, + [this](Qt::ColorScheme) { refreshColours(); }); + qApp->installEventFilter(this); +} + +bool ResultsTableModel::eventFilter(QObject* watched, QEvent* event) +{ + // The palette can change without the colour scheme changing, so both are + // watched: a stylesheet or a theme switch repaints the verdict columns. + if (event->type() == QEvent::ApplicationPaletteChange) { + refreshColours(); + } + return QAbstractTableModel::eventFilter(watched, event); +} + +void ResultsTableModel::refreshColours() +{ + if (m_points.empty()) { + return; + } + emit dataChanged(index(0, 0), + index(static_cast(m_points.size()) - 1, ColumnCount - 1), + {Qt::BackgroundRole, Qt::ForegroundRole}); +} int ResultsTableModel::rowCount(const QModelIndex& parent) const { @@ -99,8 +161,13 @@ QVariant ResultsTableModel::data(const QModelIndex& index, int role) const } if (role == Qt::BackgroundRole) { - const QColor colour = verdictColour(point->verdict); - return colour.isValid() ? QBrush{colour} : QVariant{}; + const auto colours = verdictColours(point->verdict); + return colours.background.isValid() ? QBrush{colours.background} : QVariant{}; + } + + if (role == Qt::ForegroundRole) { + const auto colours = verdictColours(point->verdict); + return colours.text.isValid() ? QBrush{colours.text} : QVariant{}; } if (role == Qt::TextAlignmentRole && index.column() != VerdictColumn && diff --git a/src/ui/windows/MainWindow.cpp b/src/ui/windows/MainWindow.cpp index 43a50a1..a3ddbe8 100644 --- a/src/ui/windows/MainWindow.cpp +++ b/src/ui/windows/MainWindow.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -14,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -79,6 +80,20 @@ namespace { return {}; } +/// Series colours that hold up on both a light and a dark plot background. +/// +/// The trace and limit colours work on either; the verified-point green does +/// not, so it is lightened where the background is dark. +[[nodiscard]] bool onDarkBackground() +{ + return QGuiApplication::palette().color(QPalette::Base).lightness() < 128; +} + +[[nodiscard]] QColor verifiedPointColour() +{ + return onDarkBackground() ? QColor{0x4C, 0xD9, 0x8C} : QColor{0x0B, 0x80, 0x43}; +} + [[nodiscard]] QVector toPoints(const Trace& trace) { QVector points; @@ -648,7 +663,7 @@ void MainWindow::onPointMeasured(const MeasurementPoint& point) PlotSeries verified; verified.id = QStringLiteral("phase2"); verified.label = tr("Verified points"); - verified.colour = QColor{0x0B, 0x80, 0x43}; + verified.colour = verifiedPointColour(); verified.style = PlotStyle::Points; for (const auto& stored : m_resultsDock->points()) { verified.points.append( @@ -1043,12 +1058,8 @@ void MainWindow::showPluginManager() void MainWindow::showAboutDialog() { - QMessageBox::about(this, - tr("About PeakEmi"), - tr("

PeakEmi %1

" - "

Cross-platform EMI pre-compliance measurement suite.

" - "

%2

") - .arg(qs(ProjectVersion), qs(ComplianceDisclaimer))); + AboutDialog dialog{this}; + dialog.exec(); } void MainWindow::closeEvent(QCloseEvent* event) diff --git a/tests/unit/MainWindowTest.cpp b/tests/unit/MainWindowTest.cpp index 6e03229..dc4fad5 100644 --- a/tests/unit/MainWindowTest.cpp +++ b/tests/unit/MainWindowTest.cpp @@ -1,23 +1,31 @@ #include #include +#include #include #include #include #include #include +#include #include #include +#include #include #include +#include #include #include +#include #include +#include #include #include #include #include +#include + using namespace peakemi; class MainWindowTest : public QObject @@ -36,6 +44,8 @@ private slots: void plotExportsImages(); void plotConvertsCoordinatesBothWays(); void resultsDockSummarisesVerdicts(); + void resultsStayReadableInEitherColourScheme(); + void aboutDialogCreditsLicenceAndSource(); void logDockEmitsConsoleCommands(); }; @@ -255,6 +265,126 @@ void MainWindowTest::resultsDockSummarisesVerdicts() QCOMPARE(dock.points().size(), 0U); } +namespace { + +/// WCAG relative luminance, the basis of the contrast ratio below. +[[nodiscard]] double relativeLuminance(const QColor& colour) +{ + const auto channel = [](int value) { + const double normalised = value / 255.0; + return normalised <= 0.03928 ? normalised / 12.92 + : std::pow((normalised + 0.055) / 1.055, 2.4); + }; + return (0.2126 * channel(colour.red())) + (0.7152 * channel(colour.green())) + + (0.0722 * channel(colour.blue())); +} + +/// WCAG contrast ratio, from 1 (identical) to 21 (black on white). The AA +/// threshold for body text is 4.5. +[[nodiscard]] double contrastRatio(const QColor& first, const QColor& second) +{ + const double a = relativeLuminance(first); + const double b = relativeLuminance(second); + return (std::max(a, b) + 0.05) / (std::min(a, b) + 0.05); +} + +[[nodiscard]] QPalette schemePalette(bool dark) +{ + QPalette palette; + const QColor window = dark ? QColor{0x1E, 0x1E, 0x1E} : QColor{0xF2, 0xF2, 0xF2}; + const QColor base = dark ? QColor{0x14, 0x14, 0x14} : QColor{0xFF, 0xFF, 0xFF}; + const QColor text = dark ? QColor{0xE6, 0xE6, 0xE6} : QColor{0x1A, 0x1A, 0x1A}; + palette.setColor(QPalette::Window, window); + palette.setColor(QPalette::WindowText, text); + palette.setColor(QPalette::Base, base); + palette.setColor(QPalette::Text, text); + return palette; +} + +} // namespace + +void MainWindowTest::resultsStayReadableInEitherColourScheme() +{ + const QPalette original = QApplication::palette(); + const auto restore = qScopeGuard([&original] { QApplication::setPalette(original); }); + + ui::ResultsTableModel model; + + std::vector points; + for (const Verdict verdict : {Verdict::Pass, Verdict::Marginal, Verdict::Fail}) { + MeasurementPoint point; + point.frequency = megahertz(100); + point.correctedAmplitude = 30.0; + point.limitValue = 40.0; + point.marginDb = 10.0; + point.verdict = verdict; + points.push_back(point); + } + model.setPoints(points); + + for (const bool dark : {false, true}) { + QApplication::setPalette(schemePalette(dark)); + + for (int row = 0; row < model.rowCount(); ++row) { + const QModelIndex index = model.index(row, ui::ResultsTableModel::VerdictColumn); + const QVariant background = model.data(index, Qt::BackgroundRole); + const QVariant foreground = model.data(index, Qt::ForegroundRole); + QVERIFY(background.isValid()); + QVERIFY(foreground.isValid()); + + const QColor onColour = foreground.value().color(); + const QColor behindColour = background.value().color(); + + // The bug this guards against: text at light-theme contrast drawn + // on a background chosen for the other scheme. + const double ratio = contrastRatio(onColour, behindColour); + const QByteArray message = + QStringLiteral("row %1 in %2 mode: contrast %3") + .arg(row) + .arg(dark ? QStringLiteral("dark") : QStringLiteral("light")) + .arg(ratio) + .toUtf8(); + QVERIFY2(ratio >= 4.5, message.constData()); + } + } +} + +void MainWindowTest::aboutDialogCreditsLicenceAndSource() +{ + const QPalette original = QApplication::palette(); + const auto restore = qScopeGuard([&original] { QApplication::setPalette(original); }); + + for (const bool dark : {false, true}) { + QApplication::setPalette(schemePalette(dark)); + + ui::AboutDialog dialog; + + QString text; + const QList labels = dialog.findChildren(); + for (const QLabel* label : labels) { + text += label->text(); + } + + QVERIFY(text.contains(QStringLiteral("Bitcrush Testing"))); + QVERIFY(text.contains(QStringLiteral("GPL-3.0-or-later"))); + QVERIFY(text.contains(ui::AboutDialog::repositoryUrl())); + QVERIFY(text.contains(ui::AboutDialog::licenceUrl())); + // The disclaimer required by CON-1 stays on the page. + QVERIFY(text.contains(QStringLiteral("PRE-COMPLIANCE"))); + + // Guards the resource wiring: the icon is compiled into a static + // library, and a missing Q_INIT_RESOURCE shows up as a blank label + // rather than as a build failure. + const auto* icon = dialog.findChild(QStringLiteral("aboutIcon")); + QVERIFY(icon != nullptr); + QVERIFY(!icon->pixmap().isNull()); + + // A link nobody can read is not a source offer. + const QPalette& scheme = dialog.palette(); + QVERIFY(contrastRatio(scheme.color(QPalette::Link), scheme.color(QPalette::Window)) >= 4.5); + } +} + void MainWindowTest::logDockEmitsConsoleCommands() { ui::LogDock dock; diff --git a/tools/screenshots/Screenshots.cpp b/tools/screenshots/Screenshots.cpp index b3ffebe..b82e64a 100644 --- a/tools/screenshots/Screenshots.cpp +++ b/tools/screenshots/Screenshots.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -25,8 +26,11 @@ #include #include #include +#include #include #include +#include +#include #include #include @@ -35,6 +39,36 @@ namespace { +/// A dark palette close to what a system dark theme produces, for rendering +/// the interface the way a dark-mode user sees it. +[[nodiscard]] QPalette darkPalette() +{ + const QColor window{0x1E, 0x1E, 0x1E}; + const QColor base{0x14, 0x14, 0x14}; + const QColor text{0xE6, 0xE6, 0xE6}; + const QColor highlight{0x2D, 0x6C, 0xDF}; + + QPalette palette; + palette.setColor(QPalette::Window, window); + palette.setColor(QPalette::WindowText, text); + palette.setColor(QPalette::Base, base); + palette.setColor(QPalette::AlternateBase, window); + palette.setColor(QPalette::Text, text); + palette.setColor(QPalette::Button, window); + palette.setColor(QPalette::ButtonText, text); + palette.setColor(QPalette::ToolTipBase, base); + palette.setColor(QPalette::ToolTipText, text); + palette.setColor(QPalette::Highlight, highlight); + palette.setColor(QPalette::Link, QColor{0x4D, 0x9B, 0xFF}); + palette.setColor(QPalette::LinkVisited, QColor{0xB4, 0x8E, 0xFF}); + palette.setColor(QPalette::HighlightedText, Qt::white); + palette.setColor(QPalette::Mid, QColor{0x3A, 0x3A, 0x3A}); + palette.setColor(QPalette::Shadow, QColor{0x50, 0x50, 0x50}); + palette.setColor(QPalette::Disabled, QPalette::Text, QColor{0x8A, 0x8A, 0x8A}); + palette.setColor(QPalette::Disabled, QPalette::WindowText, QColor{0x8A, 0x8A, 0x8A}); + return palette; +} + QTextStream& out() { static QTextStream stream{stdout}; @@ -118,8 +152,24 @@ try { peakemi::registerMetaTypes(); peakemi::drivers::registerBuiltInDrivers(); + // "--dark" renders everything in the dark colour scheme, which is how the + // legibility of the plot and the results table there gets checked rather + // than assumed. + QStringList arguments; + for (int i = 1; i < argc; ++i) { + arguments.append(QString::fromLocal8Bit(argv[i])); + } + const bool dark = arguments.removeAll(QStringLiteral("--dark")) > 0; + if (dark) { + // The hint alone does not repaint anything under the offscreen + // platform, which has no system theme to follow, so the palette is set + // explicitly. This is also what a user running a dark Qt style has. + QGuiApplication::styleHints()->setColorScheme(Qt::ColorScheme::Dark); + QApplication::setPalette(darkPalette()); + } + const QString directory = - argc > 1 ? QString::fromLocal8Bit(argv[1]) : QStringLiteral("docs/images"); + arguments.isEmpty() ? QStringLiteral("docs/images") : arguments.first(); if (!QDir{}.mkpath(directory)) { out() << "cannot create " << directory << Qt::endl; return 1; @@ -172,14 +222,30 @@ try { } out() << "run finished with " << results->points().size() << " verified point(s)" << Qt::endl; - bool ok = save(window.grab(), directory + QStringLiteral("/main-window.png")); - ok = save(plot->grab(), directory + QStringLiteral("/spectrum-plot.png")) && ok; - ok = save(results->grab(), directory + QStringLiteral("/results-table.png")) && ok; + const QString suffix = dark ? QStringLiteral("-dark") : QString{}; + bool ok = save(window.grab(), + directory + QStringLiteral("/main-window") + suffix + QStringLiteral(".png")); + ok = save(plot->grab(), + directory + QStringLiteral("/spectrum-plot") + suffix + QStringLiteral(".png")) && + ok; + ok = save(results->grab(), + directory + QStringLiteral("/results-table") + suffix + QStringLiteral(".png")) && + ok; // Raise the log tab to show the application log and the SCPI console. logDock->raise(); QCoreApplication::processEvents(QEventLoop::AllEvents, 100); - ok = save(logDock->grab(), directory + QStringLiteral("/log-console.png")) && ok; + ok = save(logDock->grab(), + directory + QStringLiteral("/log-console") + suffix + QStringLiteral(".png")) && + ok; + + // Grabbed rather than shown: the dialog is modal, so exec() would block. + peakemi::ui::AboutDialog about{&window}; + about.show(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + ok = save(about.grab(), + directory + QStringLiteral("/about-dialog") + suffix + QStringLiteral(".png")) && + ok; return ok ? 0 : 1; } catch (const std::exception& error) {