/* 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 #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 read on the dark palette, and an icon drawn as a light object (a page sheet, the PDF import icon of GitHub #919) must come through the dark theme untouched rather than inverted into a black page. QET's own vector icons must serve every size in both themes. 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 darkThemeFilesReadOnDarkPalette(); void lightIconsStayLightInDarkTheme(); void scalableIconsServeEverySize(); void breezeIconsAreSharpAtEverySize(); void tracedIconsKeepTheirPixels(); void toolbarIconIsReadable_data(); void toolbarIconIsReadable(); void hoverChangesTheIcon_data(); void hoverChangesTheIcon(); void hoverInkReadsWithAnyAccent_data(); void hoverInkReadsWithAnyAccent(); void coloredIconKeepsItsColorsOnHover(); void menuIconReadsOnHighlight_data(); void menuIconReadsOnHighlight(); void panelProjectIconStaysSmall(); void configPageIconsComeAtPageSize(); }; namespace { const double kIconRatio = 3.0; QStringList iconNames(const QString &theme) { QStringList names; for (const QString &size : {"16x16", "22x22", "24x24", "32x32", "48x48", "64x64", "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; } /** Mean color of the visible pixels: what the icon reads as, taken as a whole, on the background behind it. A page-shaped icon whose white body was inverted keeps a light border, so the lightest pixel would pass it; the mean does not. */ QColor meanVisibleColor(const QImage &image) { qint64 r = 0, g = 0, b = 0, n = 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) continue; r += c.red(); g += c.green(); b += c.blue(); ++n; } return n ? QColor(r / n, g / n, b / n) : QColor(); } /** Mean color of the ink: the visible pixels that are not saturated, as misc/make_icon_themes.py tells ink from color. A traced icon is a little ink beside a red or blue part that keeps its color in both themes, so the mean of the whole icon says nothing about the ink. An icon without ink gives the mean of all its pixels. */ QColor meanInkColor(const QImage &image) { qint64 r = 0, g = 0, b = 0, n = 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) continue; if (c.hslSaturationF() > 0.25 && qMax(c.red(), qMax(c.green(), c.blue())) > 60) continue; r += c.red(); g += c.green(); b += c.blue(); ++n; } return n ? QColor(r / n, g / n, b / n) : meanVisibleColor(image); } /** Line art is drawn in the text color: an element of Breeze's ColorScheme-Text class, or currentColor in a file without Breeze's stylesheet. Mirrors dark_svg() in misc/make_icon_themes.py. */ bool isColoredSvg(const QByteArray &svg) { static const QRegularExpression text_class(R"re(class="[^"]*\bColorScheme-(?:Text|ButtonText)\b)re"); return svg.contains("ColorScheme-") ? !text_class.match(QString::fromUtf8(svg)).hasMatch() : !svg.contains("currentColor"); } /** True when the dark theme's SVG for name in folder is what misc/make_icon_themes.py should write: a recolored copy of line art, none for colored art, or, when another size of the icon is line art, the colored file unchanged (Qt does not fall back to the light theme for a name the dark theme holds). */ bool darkCopyFits(const QString &folder, const QString &name) { QFile light(QString(":/ico/themes/qet/%1/%2.svg").arg(folder, name)); QFile dark(QString(":/ico/themes/qet-dark/%1/%2.svg").arg(folder, name)); if (!light.open(QIODevice::ReadOnly)) return false; const QByteArray svg = light.readAll(); if (!isColoredSvg(svg)) return dark.open(QIODevice::ReadOnly) && dark.readAll() != svg; return !dark.open(QIODevice::ReadOnly) || dark.readAll() == svg; } const QStringList kSizes = {"16x16", "22x22", "24x24", "32x32", "48x48", "64x64", "128x128"}; /// Icons traced from QET's PNG art: the only ones with a file in the 24 pixel folder. QStringList tracedIconFiles() { return QDir(":/ico/themes/qet/24x24").entryList({"*.svg"}, QDir::Files); } /// palette with the given selection colors, as QET::Palette::withPlatformAccent leaves them. QPalette withAccent(QPalette palette, const QColor &highlight, const QColor &highlighted_text) { for (QPalette::ColorGroup group : {QPalette::Active, QPalette::Inactive}) { palette.setColor(group, QPalette::Highlight, highlight); palette.setColor(group, QPalette::HighlightedText, highlighted_text); } return palette; } } 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)); } /** The ink of every file the generator put in the dark theme must reach 3:1 against the dark palette's window color. Inverted line art passes by a wide margin; an inverted light object (a white page turned black) sits below 2:1 and fails, which is the defect misc/make_icon_themes.py now avoids by leaving such icons to the light theme. */ void tst_qeticons::darkThemeFilesReadOnDarkPalette() { const QColor window = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Window); int checked = 0; for (const QString &size : kSizes + QStringList{"scalable"}) { QDir dir(QString(":/ico/themes/qet-dark/%1").arg(size)); const int px = size == "scalable" ? 24 : size.section('x', 0, 0).toInt(); for (const QString &file : dir.entryList({"*.png", "*.svg"}, QDir::Files)) { const QImage dark = QIcon(dir.filePath(file)).pixmap(px).toImage(); QVERIFY2(!dark.isNull(), qPrintable(file)); const QColor mean = meanInkColor(dark); QVERIFY2(mean.isValid(), qPrintable(file)); const double contrast = QET::Palette::contrastRatio(mean, window); QVERIFY2(contrast >= kIconRatio, qPrintable(QString("%1/%2 reads %3:1 on the dark window").arg(size, file).arg(contrast))); ++checked; } } QVERIFY(checked > 100); } /** Icons drawn as a light object are not in the dark theme; the theme inherits them from "qet". Asking the dark theme for them must return the light art, not a dark page (GitHub #919, the PDF import icon). */ void tst_qeticons::lightIconsStayLightInDarkTheme() { const QColor window = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Window); QIcon::setThemeName(QStringLiteral("qet-dark")); // The folio family is an SVG from 22 px up; their 16 px files are // still the light page art, as is the background swatch at 22 px. const QList> icons = { {"diagram", 16}, {"label", 16}, {"folio-new", 16}, {"folio-delete", 16}, {"folio-properties", 16}, {"diagram_bg", 22}}; for (const auto &[name, size] : icons) { QVERIFY2(!QFile::exists(QString(":/ico/themes/qet-dark/%1x%1/%2.png").arg(size).arg(name)), qPrintable(QString("%1 has a dark copy; the generator inverted a light icon").arg(name))); const QImage image = QIcon::fromTheme(name).pixmap(size).toImage(); QVERIFY2(!image.isNull(), qPrintable(name)); const QColor mean = meanVisibleColor(image); QVERIFY2(mean.lightnessF() > 0.6, qPrintable(QString("%1 comes out dark in the dark theme (lightness %2)") .arg(name).arg(mean.lightnessF()))); QVERIFY2(QET::Palette::contrastRatio(mean, window) >= kIconRatio, qPrintable(name)); } } /** QET's own vector icons live in ico/scalable/, the Breeze ones in ico/breeze/: one file for every size from the toolbar up. Line art has a recolored copy in the dark theme; colored art has none and is inherited. Each must resolve in both themes at 22, 24, 32 and 64 px, line art in dark ink on the light theme and light ink on the dark one, with no 22, 32 or 48 px PNG left beside it. Fusion's toolbar slot is 24 px, so the files are drawn on a 24 px canvas and their one pixel lines land on whole pixels there. A Breeze icon with 16 px art has it in the 16x16 folder, with no 16 px PNG beside it. */ void tst_qeticons::scalableIconsServeEverySize() { const QDir scalable(":/ico/themes/qet/scalable"); QStringList names; for (const QString &file : scalable.entryList({"*.svg"}, QDir::Files)) names << file.section('.', 0, -2); for (const QString &name : {"pdf-import", "folio-new", "folio-delete", "folio-properties", "diagram", "label"}) QVERIFY2(names.contains(name), qPrintable(name + " is not in the scalable folder")); const QByteArray dump = qgetenv("QET_TEST_DUMP_DIR"); for (const QString &name : names) { // Colored art has no text color to change; the dark theme inherits // it, as it does a colored PNG. QFile light(QString(":/ico/themes/qet/scalable/%1.svg").arg(name)); QVERIFY(light.open(QIODevice::ReadOnly)); const bool colored = isColoredSvg(light.readAll()); const bool traced = tracedIconFiles().contains(name + ".svg"); QHash light_image; QVERIFY2(darkCopyFits("scalable", name), qPrintable(name)); for (const QString &size : {"22x22", "32x32", "48x48"}) QVERIFY2(!QFile::exists(QString(":/ico/themes/qet/%1/%2.png").arg(size, name)), qPrintable(QString("%1 still has a %2 PNG that hides the SVG").arg(name, size))); QList sizes = {22, 24, 32, 64}; if (QFile::exists(QString(":/ico/themes/qet/16x16/%1.svg").arg(name))) { QVERIFY2(!QFile::exists(QString(":/ico/themes/qet/16x16/%1.png").arg(name)), qPrintable(QString("%1 has both a 16 px SVG and a 16 px PNG").arg(name))); QVERIFY2(darkCopyFits("16x16", name), qPrintable(name)); sizes.prepend(16); } for (const QString &theme : {"qet", "qet-dark"}) { QIcon::setThemeName(theme); const QIcon icon = QIcon::fromTheme(name); QVERIFY2(!icon.isNull(), qPrintable(name)); for (int size : sizes) { const QPixmap pixmap = icon.pixmap(size); QCOMPARE(pixmap.width(), size); if (!dump.isEmpty()) pixmap.save(QString("%1/%2-%3-%4.png").arg(QString::fromLocal8Bit(dump), name, theme).arg(size)); const QColor mean = traced ? meanInkColor(pixmap.toImage()) : meanVisibleColor(pixmap.toImage()); QVERIFY2(mean.isValid(), qPrintable(QString("%1 in %2 at %3 px is empty").arg(name, theme).arg(size))); if (colored) continue; // A traced icon keeps its light fills and its colors; only // its ink changes, so its ink must be lighter than it was. // The size of an icon that is colored at that size only is // the light file. if (traced && theme == "qet") light_image[size] = pixmap.toImage(); else if (traced && pixmap.toImage() == light_image[size]) continue; else if (traced) QVERIFY2(mean.lightnessF() > meanInkColor(light_image[size]).lightnessF(), qPrintable(QString("%1 dark theme at %2 px: ink no lighter than in the light theme").arg(name).arg(size))); else if (theme == "qet") QVERIFY2(mean.lightnessF() < 0.5, qPrintable(QString("%1 light theme at %2 px: lightness %3").arg(name).arg(size).arg(mean.lightnessF()))); else QVERIFY2(mean.lightnessF() > 0.6, qPrintable(QString("%1 dark theme at %2 px: lightness %3").arg(name).arg(size).arg(mean.lightnessF()))); } } } } /** Qt draws an SVG at the size asked for, so art drawn on a 16 pixel grid blurs at 24 pixels and is sharp at 32. Each size folder of a Breeze icon (misc/make_icon_themes.py) must hold art whose canvas divides the folder's size, and Qt must pick that file at that size: the icon's pixmap must equal the file rendered alone. The same holds on a 2x screen, for twice the pixels. */ void tst_qeticons::breezeIconsAreSharpAtEverySize() { const QList> folders = { {"16x16", 16}, {"22x22", 22}, {"scalable", 24}, {"32x32", 32}, {"48x48", 48}, {"64x64", 64}, {"128x128", 128}}; const QRegularExpression canvas(R"re(]*?\s(?:viewBox="0 0 (\d+) \d+"|width="(\d+)"))re"); // Breeze icons are the ones with SVG art in the 22 pixel folder that // are not traced icons. QStringList files = QDir(":/ico/themes/qet/22x22").entryList({"*.svg"}, QDir::Files); for (const QString &traced : tracedIconFiles()) files.removeAll(traced); QVERIFY(files.size() > 100); const QByteArray dump = qgetenv("QET_TEST_DUMP_DIR"); QStringList blurred; for (const QString &theme : {"qet", "qet-dark"}) { QIcon::setThemeName(theme); for (const QString &file : files) { const QString name = file.section('.', 0, -2); const QIcon icon = QIcon::fromTheme(name); for (const auto &[folder, size] : folders) { QString path = QString(":/ico/themes/%1/%2/%3").arg(theme, folder, file); if (!QFile::exists(path)) // colored art: the dark theme inherits it path = QString(":/ico/themes/qet/%1/%2").arg(folder, file); QFile svg(path); QVERIFY2(svg.open(QIODevice::ReadOnly), qPrintable(path)); const QRegularExpressionMatch match = canvas.match(QString::fromUtf8(svg.readAll())); QVERIFY2(match.hasMatch(), qPrintable(path)); const int grid = match.captured(1).isEmpty() ? match.captured(2).toInt() : match.captured(1).toInt(); if (size % grid) blurred << QString("%1: %2 px art at %3 px").arg(path).arg(grid).arg(size); const QImage image = icon.pixmap(size).toImage(); if (image != QIcon(path).pixmap(size).toImage()) blurred << QString("%1 in %2 at %3 px: Qt drew another file than %4").arg(name, theme).arg(size).arg(path); if (!dump.isEmpty()) image.save(QString("%1/%2-%3-%4.png").arg(QString::fromLocal8Bit(dump), name, theme).arg(size)); // A 2x screen takes the Scale=2 twin of the folder; the // 24 pixel slot keeps the scalable file, sharp at 48. if (folder == "scalable") continue; QString twin = QString(":/ico/themes/%1/%2@2/%3").arg(theme, folder, file); if (!QFile::exists(twin)) twin = QString(":/ico/themes/qet/%1@2/%2").arg(folder, file); QFile svg2(twin); QVERIFY2(svg2.open(QIODevice::ReadOnly), qPrintable(twin)); const QRegularExpressionMatch match2 = canvas.match(QString::fromUtf8(svg2.readAll())); QVERIFY2(match2.hasMatch(), qPrintable(twin)); const int grid2 = match2.captured(1).isEmpty() ? match2.captured(2).toInt() : match2.captured(1).toInt(); if ((2 * size) % grid2) blurred << QString("%1: %2 px art at %3 px").arg(twin).arg(grid2).arg(2 * size); if (icon.pixmap(QSize(size, size), 2.0).toImage() != QIcon(twin).pixmap(QSize(size, size), 2.0).toImage()) blurred << QString("%1 in %2 at %3 px 2x: Qt drew another file than %4").arg(name, theme).arg(size).arg(twin); } } } QVERIFY2(blurred.isEmpty(), qPrintable(blurred.join('\n'))); } /** A traced icon (ico/traced/, misc/make_icon_themes.py) is the PNG copied pixel by pixel at 16 and 22 pixels, so those files and the 24 pixel canvas must be drawn unscaled. At every size, at 1x and at 2x, Qt must pick the folder's own file. */ void tst_qeticons::tracedIconsKeepTheirPixels() { const QList> folders = { {"16x16", 16}, {"22x22", 22}, {"24x24", 24}, {"32x32", 32}, {"48x48", 48}, {"64x64", 64}, {"128x128", 128}}; const QRegularExpression canvas(R"re(]*?\sviewBox="0 0 (\d+) \d+")re"); const QStringList files = tracedIconFiles(); QVERIFY(files.size() > 50); const QByteArray dump = qgetenv("QET_TEST_DUMP_DIR"); QStringList wrong; for (const QString &theme : {"qet", "qet-dark"}) { QIcon::setThemeName(theme); // the file the theme holds for a folder: its own, else the light theme's auto themeFile = [&theme](const QString &folder, const QString &file) { const QString path = QString(":/ico/themes/%1/%2/%3").arg(theme, folder, file); return QFile::exists(path) ? path : QString(":/ico/themes/qet/%1/%2").arg(folder, file); }; for (const QString &file : files) { const QString name = file.section('.', 0, -2); const QIcon icon = QIcon::fromTheme(name); for (const auto &[folder, size] : folders) { QVERIFY2(!QFile::exists(QString(":/ico/themes/qet/%1/%2.png").arg(folder, name)), qPrintable(QString("%1 still has a %2 PNG").arg(name, folder))); const QString path = themeFile(folder, file); QFile svg(path); QVERIFY2(svg.open(QIODevice::ReadOnly), qPrintable(path)); if (size <= 24) { const QRegularExpressionMatch match = canvas.match(QString::fromUtf8(svg.readAll())); QVERIFY2(match.hasMatch(), qPrintable(path)); if (match.captured(1).toInt() != size) wrong << QString("%1: %2 px art at %3 px").arg(path, match.captured(1)).arg(size); } const QImage image = icon.pixmap(size).toImage(); if (image != QIcon(path).pixmap(size).toImage()) wrong << QString("%1 in %2 at %3 px: Qt drew another file than %4").arg(name, theme).arg(size).arg(path); if (!dump.isEmpty()) image.save(QString("%1/%2-%3-%4.png").arg(QString::fromLocal8Bit(dump), name, theme).arg(size)); } // A 2x screen takes the Scale=2 twin of the folder. for (const auto &[folder, size] : folders) { const QString path = themeFile(folder + "@2", file); const QImage image = icon.pixmap(QSize(size, size), 2.0).toImage(); QCOMPARE(image.width(), 2 * size); if (image != QIcon(path).pixmap(QSize(size, size), 2.0).toImage()) wrong << QString("%1 in %2 at %3 px 2x: Qt drew another file than %4").arg(name, theme).arg(size).arg(path); } } } QVERIFY2(wrong.isEmpty(), qPrintable(wrong.join('\n'))); } 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; // A platform accent QET keeps because its selection text reads on // it: macOS's green selection color comes with black text. Lightening // it toward 3:1 against a white face can only end at white, which // made every hovered line-art icon vanish on a light palette. QTest::newRow("light-pale-accent") << "qet" << withAccent(QET::Palette::fusionLight(), QColor(198, 231, 188), Qt::black) << false; QTest::newRow("dark-pale-accent") << "qet-dark" << withAccent(QET::Palette::fusionDark(), QColor(198, 231, 188), Qt::black) << false; } /** 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("titleblock-bottom"); // colored 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 } /** The configuration dialogs list their pages with 64 or 128 pixel icons (ConfigDialog::ConfigDialog). A page whose icon exists at 22 pixels only comes out as a 22 pixel stamp among 128 pixel neighbors, which the terminal-strip page of the project dialog and the shortcuts page of the settings dialog did. */ void tst_qeticons::configPageIconsComeAtPageSize() { const QStringList pages = {"settings", "project", "diagram", "plasmagik", "printer", "document-export", "terminalstrip", "configure-shortcuts"}; QStringList small; for (const QString &theme : {"qet", "qet-dark"}) { QIcon::setThemeName(theme); for (const QString &name : pages) { const QIcon icon = QIcon::fromTheme(name); QVERIFY2(!icon.isNull(), qPrintable(QString("%1 missing in theme %2").arg(name, theme))); const QSize size = icon.actualSize(QSize(128, 128)); if (size.width() < 128 || size.height() < 128) small << QString("%1 in %2 at %3x%4").arg(name, theme).arg(size.width()).arg(size.height()); } } QIcon::setThemeName("qet"); QVERIFY2(small.isEmpty(), qPrintable("page icons short of 128 pixels: " + small.join(", "))); } /** Whatever accent the platform hands QET, the hover ink must read on the hovered button face of both palettes: a sweep over hues at every lightness, including the pale and the near-black ones. */ void tst_qeticons::hoverInkReadsWithAnyAccent_data() { QTest::addColumn("palette"); for (int lightness = 10; lightness <= 250; lightness += 40) for (int hue = 0; hue < 360; hue += 60) { const QColor accent = QColor::fromHsl(hue, 200, lightness); const QColor text = QET::Palette::contrastRatio(accent, Qt::white) >= 4.5 ? Qt::white : Qt::black; QTest::newRow(qPrintable(QString("light-%1").arg(accent.name()))) << withAccent(QET::Palette::fusionLight(), accent, text); QTest::newRow(qPrintable(QString("dark-%1").arg(accent.name()))) << withAccent(QET::Palette::fusionDark(), accent, text); } } void tst_qeticons::hoverInkReadsWithAnyAccent() { QFETCH(QPalette, palette); const QColor face = palette.color(QPalette::Active, QPalette::Light); const QColor ink = QETStyle::hoverColor(palette); const double contrast = QET::Palette::contrastRatio(ink, face); QVERIFY2(contrast >= kIconRatio, qPrintable(QString("hover ink %1 reads %2:1 on the face %3").arg(ink.name()).arg(contrast).arg(face.name()))); } 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"