/* Copyright 2006-2026 The QElectroTech Team This file is part of QElectroTech. QElectroTech is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or (at your option) any later version. QElectroTech is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with QElectroTech. If not, see . */ #include #include #include #include #include #include #include #include #include #include #include #include "inkcontrast.h" #include "qeticons.h" #include "qetpalette.h" #include "qetstyle.h" /** Checks on QET's two icon themes, "qet" and "qet-dark", generated by misc/make_icon_themes.py into ico/icon-themes.qrc, and on the icon table that uses them. Every icon name must resolve in both themes. The dark theme's files must be light ink. A toolbar button painted with Fusion must show its icon at 3:1 (WCAG 1.4.11) in both themes, with the disabled state reading weaker than the enabled one, which is what was reversed on macOS before (GitHub #466, #870). And the icons the elements panel draws in its 50 px slots must stay small. */ class tst_qeticons : public QObject { Q_OBJECT private slots: void initTestCase(); void everyIconResolvesInBothThemes(); void darkThemeFilesAreLightInk(); void toolbarIconIsReadable_data(); void toolbarIconIsReadable(); void hoverChangesTheIcon_data(); void hoverChangesTheIcon(); void coloredIconKeepsItsColorsOnHover(); void menuIconReadsOnHighlight_data(); void menuIconReadsOnHighlight(); void panelProjectIconStaysSmall(); }; namespace { const double kIconRatio = 3.0; QStringList iconNames(const QString &theme) { QStringList names; for (const QString &size : {"16x16", "22x22", "32x32", "48x48", "128x128", "scalable"}) { QDir dir(QString(":/ico/themes/%1/%2").arg(theme, size)); for (const QString &file : dir.entryList(QDir::Files)) names << file.section('.', 0, -2); } names.removeDuplicates(); names.sort(); return names; } /// Lightness of the lightest visible pixel: the ink, in a dark-theme file. double maxLightness(const QImage &image) { double max = 0.0; for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x) { const QColor c = image.pixelColor(x, y); if (c.alpha() > 64) max = qMax(max, c.lightnessF()); } return max; } } void tst_qeticons::initTestCase() { QStringList paths = QIcon::themeSearchPaths(); paths.prepend(QStringLiteral(":/ico/themes")); QIcon::setThemeSearchPaths(paths); QIcon::setThemeName(QStringLiteral("qet")); QVERIFY2(!iconNames("qet").isEmpty(), "light theme has no icons: is ico/icon-themes.qrc compiled in?"); } void tst_qeticons::everyIconResolvesInBothThemes() { const QStringList names = iconNames("qet"); for (const QString &theme : {"qet", "qet-dark"}) { QIcon::setThemeName(theme); QVERIFY(QIcon::hasThemeIcon("list-add")); for (const QString &name : names) QVERIFY2(!QIcon::fromTheme(name).isNull(), qPrintable(QString("%1 missing in theme %2").arg(name, theme))); } // Names the icon table relies on that come from the alias list in // misc/make_icon_themes.py, not from a file of that name at 22 pixels. QIcon::setThemeName("qet"); for (const QString &name : {"folio-new", "folio-delete", "folio-properties", "conductor-reset"}) QVERIFY2(!QIcon::fromTheme(name).pixmap(22).isNull(), qPrintable(name)); } void tst_qeticons::darkThemeFilesAreLightInk() { int checked = 0; for (const QString &size : {"16x16", "22x22", "32x32"}) { QDir dir(QString(":/ico/themes/qet-dark/%1").arg(size)); for (const QString &file : dir.entryList({"*.png"}, QDir::Files)) { const QImage dark(dir.filePath(file)); const QImage light(QString(":/ico/themes/qet/%1/%2").arg(size, file)); QVERIFY2(!dark.isNull() && !light.isNull(), qPrintable(file)); // A page-shaped icon keeps its white fill dark after inversion, // so the mean would mislead; what matters is that the ink is light. QVERIFY2(maxLightness(dark) >= 0.8, qPrintable(QString("%1/%2 has no light ink").arg(size, file))); ++checked; } } QVERIFY(checked > 100); } void tst_qeticons::toolbarIconIsReadable_data() { QTest::addColumn("theme"); QTest::addColumn("palette"); QTest::newRow("light") << "qet" << QET::Palette::fusionLight(); QTest::newRow("dark") << "qet-dark" << QET::Palette::fusionDark(); } /** A line-art icon on a Fusion tool button, as in the diagram editor toolbar. Enabled must reach 3:1; disabled must be weaker than enabled. */ void tst_qeticons::toolbarIconIsReadable() { QFETCH(QString, theme); QFETCH(QPalette, palette); QIcon::setThemeName(theme); QApplication::setStyle(QStyleFactory::create("Fusion")); QApplication::setPalette(palette); QMainWindow window; QToolBar *toolbar = window.addToolBar("view"); auto make = [&](const char *name, bool enabled) { auto *button = new QToolButton; button->setIcon(QIcon::fromTheme("zoom-fit-best")); button->setAutoRaise(true); button->setEnabled(enabled); button->setObjectName(name); toolbar->addWidget(button); return button; }; QToolButton *enabled = make("enabled", true); QToolButton *disabled = make("disabled", false); window.resize(300, 100); window.show(); QVERIFY(QTest::qWaitForWindowExposed(&window)); const double enabled_contrast = QET::Test::inkContrast(QET::Test::grab(enabled, "enabled"), enabled->rect()); const double disabled_contrast = QET::Test::inkContrast(QET::Test::grab(disabled, "disabled"), disabled->rect()); QVERIFY2(enabled_contrast >= kIconRatio, qPrintable(QString("enabled icon: %1").arg(enabled_contrast))); QVERIFY2(disabled_contrast < enabled_contrast, qPrintable(QString("disabled %1 reads better than enabled %2") .arg(disabled_contrast).arg(enabled_contrast))); } namespace { /** Paint a tool button through the style with the given extra states, the way QToolButton::paintEvent does, so hover can be rendered without a real pointer. */ QImage renderToolButton(QToolButton *button, QStyle::State extra, const char *name) { QStyleOptionToolButton option; option.initFrom(button); option.state |= QStyle::State_Enabled | QStyle::State_AutoRaise | QStyle::State_Raised | extra; option.icon = button->icon(); option.iconSize = button->iconSize(); option.subControls = QStyle::SC_ToolButton; option.features = QStyleOptionToolButton::None; option.toolButtonStyle = Qt::ToolButtonIconOnly; QImage image(button->size(), QImage::Format_ARGB32_Premultiplied); image.fill(option.palette.color(QPalette::Window)); QPainter painter(&image); button->style()->drawComplexControl(QStyle::CC_ToolButton, &option, &painter, button); painter.end(); return QET::Test::dump(image, name); } QToolButton *toolButton(QMainWindow &window, const QIcon &icon) { QToolBar *toolbar = window.addToolBar("view"); auto *button = new QToolButton; button->setIcon(icon); button->setAutoRaise(true); toolbar->addWidget(button); window.resize(300, 100); window.show(); return button; } } void tst_qeticons::hoverChangesTheIcon_data() { QTest::addColumn("theme"); QTest::addColumn("palette"); QTest::addColumn("checked"); QTest::newRow("light") << "qet" << QET::Palette::fusionLight() << false; QTest::newRow("dark") << "qet-dark" << QET::Palette::fusionDark() << false; // A toggle that is on: Fusion draws it sunken, and hovering it changes // nothing in the frame, so the icon is the only hover signal there. QTest::newRow("light-checked") << "qet" << QET::Palette::fusionLight() << true; QTest::newRow("dark-checked") << "qet-dark" << QET::Palette::fusionDark() << true; } /** Hovering a line-art icon must change the icon itself, not only the button frame, and the hovered icon must still reach 3:1 (GitHub #870). QETStyle answers QIcon::Active with the icon tinted in the highlight color; the built-in styles return it unchanged. */ void tst_qeticons::hoverChangesTheIcon() { QFETCH(QString, theme); QFETCH(QPalette, palette); QFETCH(bool, checked); QIcon::setThemeName(theme); QApplication::setStyle(new QETStyle(QStyleFactory::create("Fusion"))); QApplication::setPalette(palette); QMainWindow window; QToolButton *button = toolButton(window, QIcon::fromTheme("zoom-fit-best")); button->setCheckable(checked); button->setChecked(checked); QVERIFY(QTest::qWaitForWindowExposed(&window)); const QStyle::State on = checked ? (QStyle::State_On | QStyle::State_Sunken) : QStyle::State_None; const QImage normal = renderToolButton(button, on, "normal"); const QImage hover = renderToolButton(button, on | QStyle::State_MouseOver, "hover"); // Inside the frame Fusion draws around a hovered button, so the ink // measured is the icon's, not the frame's. const QRect inside = button->rect().adjusted(3, 3, -3, -3); const QColor normal_ink(QET::Test::ink(normal, inside)); const QColor hover_ink(QET::Test::ink(hover, inside)); QVERIFY2(hover_ink != normal_ink, qPrintable(QString("hover leaves the icon ink at %1").arg(normal_ink.name()))); // The tint, give or take the icon's own anti-aliasing. const QColor expected = QETStyle::hoverColor(palette); const int distance = qMax(qMax(qAbs(hover_ink.red() - expected.red()), qAbs(hover_ink.green() - expected.green())), qAbs(hover_ink.blue() - expected.blue())); QVERIFY2(distance <= 16, qPrintable(QString("hovered ink %1, expected %2").arg(hover_ink.name(), expected.name()))); const double hover_contrast = QET::Test::inkContrast(hover, inside); QVERIFY2(hover_contrast >= kIconRatio, qPrintable(QString("hovered icon: %1").arg(hover_contrast))); } /** A colored icon (old Oxygen art) is not line art, so hover leaves its pixels alone. */ void tst_qeticons::coloredIconKeepsItsColorsOnHover() { QIcon::setThemeName(QStringLiteral("qet")); QApplication::setStyle(new QETStyle(QStyleFactory::create("Fusion"))); QApplication::setPalette(QET::Palette::fusionLight()); const QIcon icon = QIcon::fromTheme("document-open"); // colored Oxygen art QVERIFY(!icon.isNull()); const QPixmap normal = icon.pixmap(QSize(22, 22), 1.0, QIcon::Normal); const QPixmap active = icon.pixmap(QSize(22, 22), 1.0, QIcon::Active); QVERIFY(!QETStyle::isLineArt(normal.toImage())); QCOMPARE(active.toImage(), normal.toImage()); } void tst_qeticons::menuIconReadsOnHighlight_data() { toolbarIconIsReadable_data(); } /** Fusion asks for the QIcon::Active pixmap of a highlighted menu item's icon too, and paints it on the highlight bar. The hover tint would vanish there, so on a menu item the icon must read against the bar at 3:1 instead: the same ink as the item's text. */ void tst_qeticons::menuIconReadsOnHighlight() { QFETCH(QString, theme); QFETCH(QPalette, palette); QIcon::setThemeName(theme); QApplication::setStyle(new QETStyle(QStyleFactory::create("Fusion"))); QApplication::setPalette(palette); QMenu menu; QAction *action = menu.addAction(QIcon::fromTheme("zoom-fit-best"), "Zoom to fit"); menu.resize(160, 32); QStyleOptionMenuItem option; option.initFrom(&menu); option.state |= QStyle::State_Enabled | QStyle::State_Selected; option.menuItemType = QStyleOptionMenuItem::Normal; option.checkType = QStyleOptionMenuItem::NotCheckable; option.icon = action->icon(); option.text = action->text(); option.maxIconWidth = 22; option.rect = QRect(0, 0, 160, 24); QImage image(160, 24, QImage::Format_ARGB32_Premultiplied); image.fill(palette.color(QPalette::Window)); QPainter painter(&image); menu.style()->drawControl(QStyle::CE_MenuItem, &option, &painter, &menu); painter.end(); QET::Test::dump(image, "menu-highlight"); // The icon column: the first maxIconWidth pixels plus Fusion's margin. const QRect icon_column(0, 0, 30, 24); const QColor bar(QET::Test::background(image, icon_column)); QCOMPARE(bar, palette.color(QPalette::Active, QPalette::Highlight)); const double contrast = QET::Test::inkContrast(image, icon_column); QVERIFY2(contrast >= kIconRatio, qPrintable(QString("highlighted menu icon: %1").arg(contrast))); } /** The elements panel sets a 50 px icon size so element previews are large. Its project root item uses ProjectFileGP, whose theme name also has a 128 px file for the configuration dialog. On a 2x display the theme loader would pick that file for a 50 px request and fill the slot, so the entry must not resolve through the theme name. */ void tst_qeticons::panelProjectIconStaysSmall() { #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) QSKIP("QIcon::pixmap(size, devicePixelRatio) needs Qt 6"); #else QIcon::setThemeName(QStringLiteral("qet")); QET::Icons::initIcons(); const QPixmap panel = QET::Icons::ProjectFileGP.pixmap(QSize(50, 50), 2.0); QVERIFY(!panel.isNull()); const int logical = qRound(panel.width() / panel.devicePixelRatio()); QVERIFY2(logical <= 22, qPrintable(QString("project root icon is %1 px in a 50 px slot").arg(logical))); // The configuration dialog's page list still gets the large file. QCOMPARE(QET::Icons::Projects.pixmap(QSize(128, 128), 1.0).width(), 128); #endif } int main(int argc, char **argv) { if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); QApplication app(argc, argv); tst_qeticons test; QTEST_SET_MAIN_SOURCE_PATH return QTest::qExec(&test, argc, argv); } #include "tst_qeticons.moc"