Files
qelectrotech-source-mirror/tests/qttest/tst_qeticons.cpp
Laurent Trinques f574d28340 Merge pull request #947 from jp2images/fix-dark-theme-light-icons
Redraw the Add PDF and folio icons as SVGs, stop inverting page-shaped icons in the dark theme
2026-09-19 20:22:29 +02:00

486 lines
18 KiB
C++

/*
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 <http://www.gnu.org/licenses/>.
*/
#include <QtTest>
#include <QApplication>
#include <QDir>
#include <QFile>
#include <QIcon>
#include <QMainWindow>
#include <QMenu>
#include <QPainter>
#include <QStyleFactory>
#include <QStyleOption>
#include <QToolBar>
#include <QToolButton>
#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 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;
}
/**
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();
}
const QStringList kSizes = {"16x16", "22x22", "32x32", "48x48", "128x128"};
}
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));
}
/**
Every file the generator put in the dark theme, taken as a whole,
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)
{
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));
QVERIFY2(!dark.isNull(), qPrintable(file));
const QColor mean = meanVisibleColor(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<QPair<QString, int>> 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/: one file for every size
from the toolbar up, with a recolored copy in the dark theme. Each
must resolve in both themes at 22, 24, 32 and 64 px, dark ink on the
light theme and light ink on the dark one, with no 22 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.
*/
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)
{
QVERIFY2(QFile::exists(QString(":/ico/themes/qet-dark/scalable/%1.svg").arg(name)), qPrintable(name));
QVERIFY2(!QFile::exists(QString(":/ico/themes/qet/22x22/%1.png").arg(name)),
qPrintable(QString("%1 still has a 22 px PNG that hides the SVG").arg(name)));
for (const QString &theme : {"qet", "qet-dark"})
{
QIcon::setThemeName(theme);
const QIcon icon = QIcon::fromTheme(name);
QVERIFY2(!icon.isNull(), qPrintable(name));
for (int size : {22, 24, 32, 64})
{
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 = meanVisibleColor(pixmap.toImage());
QVERIFY2(mean.isValid(), qPrintable(QString("%1 in %2 at %3 px is empty").arg(name, theme).arg(size)));
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())));
}
}
}
}
void tst_qeticons::toolbarIconIsReadable_data()
{
QTest::addColumn<QString>("theme");
QTest::addColumn<QPalette>("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<QString>("theme");
QTest::addColumn<QPalette>("palette");
QTest::addColumn<bool>("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"