/*
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"