From f0d05f3450e86aa1404d98e090e363f5175ea478 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Sat, 10 Oct 2026 09:08:32 -0700 Subject: [PATCH] fix(gui): AetherRX and AetherTX check boxes and radio buttons draw their canon indicators again. Principle VIII. ModemChrome's canon look takes the sheet's own QCheckBox/QRadioButton indicator rules out before appending the painted canon indicators. The pattern ended each rule at the first "}", but in the canon look those rules hold {{color.canon.*}} placeholders, so every match stopped inside a token and left "}};" fragments behind. Qt rejects a stylesheet it cannot parse as a whole, so the stage column and the AetherNR page lost the entire canon chrome sheet: unchecked boxes and radios drew nothing, a checked box was a bare tick and a selected radio a small dot. A rule body now ends at the first brace outside a token. modem_chrome_test: - canonSheetLeavesNoRuleFragments: with tokens set aside, every brace in the canon sheet opens or closes a rule at the top level. - canonIndicatorsRenderTheirImages: a check box and a radio button under the canon sheet draw the same pixels as under the canon indicator rules alone, checked and unchecked. Both fail with the old pattern. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/gui/ModemChrome.cpp | 13 ++++--- tests/modem_chrome_test.cpp | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/gui/ModemChrome.cpp b/src/gui/ModemChrome.cpp index 18de184907..ce6cb8bb45 100644 --- a/src/gui/ModemChrome.cpp +++ b/src/gui/ModemChrome.cpp @@ -396,12 +396,15 @@ QScrollBar::sub-line:vertical { // The canon look's check boxes and radio buttons are the painted canon // indicators alone: the sheet's own indicator rules come out first, since // its :checked ones are more specific than the generic indicator rule and - // would draw a border around the image. Lists (the AetherRX/AetherTX - // profile libraries) sit on a nested card; the modem look leaves them to - // the application sheet. + // would draw a border around the image. A rule body here holds {{token}} + // placeholders, so it ends at the first brace outside one: ending at the + // first "}" would stop inside a token and leave the rest of the rule + // behind, breaking the sheet so the painted indicators never apply. Lists + // (the AetherRX/AetherTX profile libraries) sit on a nested card; the + // modem look leaves them to the application sheet. if (look == Look::Canon) { - static const QRegularExpression ownIndicatorRules( - QStringLiteral(R"((?:QCheckBox|QRadioButton)::indicator[^{]*\{[^}]*\}\s*)")); + static const QRegularExpression ownIndicatorRules(QStringLiteral( + R"((?:QCheckBox|QRadioButton)::indicator[^{]*\{(?:\{\{[^{}]*\}\}|[^{}])*\}\s*)")); sheet.remove(ownIndicatorRules); sheet += canonIndicatorRules(); sheet += QStringLiteral( diff --git a/tests/modem_chrome_test.cpp b/tests/modem_chrome_test.cpp index 50d982e6ac..5fe1ca33d3 100644 --- a/tests/modem_chrome_test.cpp +++ b/tests/modem_chrome_test.cpp @@ -19,11 +19,18 @@ #include "TestSettingsProfile.h" #include +#include +#include +#include +#include +#include +#include #include #include #include #include +#include using namespace AetherSDR; @@ -38,6 +45,8 @@ private slots: void everyThemeTokenInTheSheetResolves(); void checkedLosesToDisabled(); void canonLookPaintsItsIndicators(); + void canonSheetLeavesNoRuleFragments(); + void canonIndicatorsRenderTheirImages(); }; namespace { @@ -51,6 +60,29 @@ int baseFontSize(const QString& sheet) return m.hasMatch() ? m.captured(1).toInt() : -1; } +// The pixels of `button`'s indicator under `sheet` (theme-resolved), so two +// sheets can be compared by what they draw rather than by their text. +template +QImage indicatorPixels(const QString& sheet, bool checked) +{ + Button button(QStringLiteral("x")); + button.setStyleSheet(ThemeManager::instance().resolve(sheet)); + button.setChecked(checked); + button.ensurePolished(); + button.resize(button.sizeHint()); + QStyleOptionButton option; + option.initFrom(&button); + const auto element = std::is_same_v ? QStyle::SE_CheckBoxIndicator + : QStyle::SE_RadioButtonIndicator; + const QRect rect = button.style()->subElementRect(element, &option, &button); + // Over one fixed backdrop, without the widget's own background, so only + // what the indicator draws can differ. + QImage pixels(rect.size(), QImage::Format_ARGB32_Premultiplied); + pixels.fill(QColor(0x40, 0x20, 0x60)); + button.render(&pixels, QPoint(), QRegion(rect), QWidget::DrawChildren); + return pixels; +} + } // namespace void ModemChromeTest::bothScalesCarryEveryStructuralSelector() @@ -134,6 +166,46 @@ void ModemChromeTest::canonLookPaintsItsIndicators() } } +// Taking the sheet's own indicator rules out must take each whole rule. Their +// bodies hold {{token}} placeholders, and a rule cut at the first "}" leaves +// "}};" fragments that break the sheet, so the painted indicators never apply. +void ModemChromeTest::canonSheetLeavesNoRuleFragments() +{ + static const QRegularExpression token(QStringLiteral(R"(\{\{[^{}]*\}\})")); + for (const auto scale : {ModemChrome::Scale::Dialog, ModemChrome::Scale::Compact}) { + QString structure = ModemChrome::styleSheet(scale, ModemChrome::Look::Canon); + structure.replace(token, QStringLiteral("token")); + int depth = 0; + for (const QChar ch : structure) { + if (ch == QLatin1Char('{')) { + QVERIFY2(++depth == 1, "a rule opens inside another rule"); + } else if (ch == QLatin1Char('}')) { + QVERIFY2(--depth == 0, "a closing brace has no rule to close"); + } + } + QCOMPARE(depth, 0); + } +} + +// In the canon look a check box and a radio button draw exactly what the +// painted canon indicators draw on their own, checked and unchecked: the rest +// of the sheet neither hides the images nor draws over them. +void ModemChromeTest::canonIndicatorsRenderTheirImages() +{ + const QString indicators = canonIndicatorRules(); + QVERIFY(!indicators.isEmpty()); + const QString canon = ModemChrome::styleSheet(ModemChrome::Scale::Dialog, + ModemChrome::Look::Canon); + for (const bool checked : {false, true}) { + const QImage box = indicatorPixels(canon, checked); + QVERIFY(!box.isNull()); + QCOMPARE(box, indicatorPixels(indicators, checked)); + const QImage radio = indicatorPixels(canon, checked); + QVERIFY(!radio.isNull()); + QCOMPARE(radio, indicatorPixels(indicators, checked)); + } +} + void ModemChromeTest::compactIsSmallerButNotADifferentPalette() { const QString dialog = ModemChrome::styleSheet(ModemChrome::Scale::Dialog);