mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-04 18:04:14 +02:00
Tested that every icon size uses art drawn for it and reads on light and dark
This commit is contained in:
+245
-21
@@ -23,6 +23,7 @@
|
||||
#include <QMainWindow>
|
||||
#include <QMenu>
|
||||
#include <QPainter>
|
||||
#include <QRegularExpression>
|
||||
#include <QStyleFactory>
|
||||
#include <QStyleOption>
|
||||
#include <QToolBar>
|
||||
@@ -59,6 +60,8 @@ class tst_qeticons : public QObject
|
||||
void darkThemeFilesReadOnDarkPalette();
|
||||
void lightIconsStayLightInDarkTheme();
|
||||
void scalableIconsServeEverySize();
|
||||
void breezeIconsAreSharpAtEverySize();
|
||||
void tracedIconsKeepTheirPixels();
|
||||
void toolbarIconIsReadable_data();
|
||||
void toolbarIconIsReadable();
|
||||
void hoverChangesTheIcon_data();
|
||||
@@ -78,7 +81,7 @@ namespace {
|
||||
QStringList iconNames(const QString &theme)
|
||||
{
|
||||
QStringList names;
|
||||
for (const QString &size : {"16x16", "22x22", "32x32", "48x48", "128x128", "scalable"})
|
||||
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))
|
||||
@@ -108,7 +111,66 @@ namespace {
|
||||
return n ? QColor(r / n, g / n, b / n) : QColor();
|
||||
}
|
||||
|
||||
const QStringList kSizes = {"16x16", "22x22", "32x32", "48x48", "128x128"};
|
||||
/**
|
||||
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)
|
||||
@@ -150,8 +212,8 @@ void tst_qeticons::everyIconResolvesInBothThemes()
|
||||
}
|
||||
|
||||
/**
|
||||
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
|
||||
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
|
||||
@@ -161,14 +223,15 @@ void tst_qeticons::darkThemeFilesReadOnDarkPalette()
|
||||
{
|
||||
const QColor window = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Window);
|
||||
int checked = 0;
|
||||
for (const QString &size : kSizes)
|
||||
for (const QString &size : kSizes + QStringList{"scalable"})
|
||||
{
|
||||
QDir dir(QString(":/ico/themes/qet-dark/%1").arg(size));
|
||||
for (const QString &file : dir.entryList({"*.png"}, QDir::Files))
|
||||
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(dir.filePath(file));
|
||||
const QImage dark = QIcon(dir.filePath(file)).pixmap(px).toImage();
|
||||
QVERIFY2(!dark.isNull(), qPrintable(file));
|
||||
const QColor mean = meanVisibleColor(dark);
|
||||
const QColor mean = meanInkColor(dark);
|
||||
QVERIFY2(mean.isValid(), qPrintable(file));
|
||||
const double contrast = QET::Palette::contrastRatio(mean, window);
|
||||
QVERIFY2(contrast >= kIconRatio,
|
||||
@@ -208,12 +271,15 @@ void tst_qeticons::lightIconsStayLightInDarkTheme()
|
||||
}
|
||||
|
||||
/**
|
||||
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.
|
||||
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()
|
||||
{
|
||||
@@ -227,23 +293,51 @@ void tst_qeticons::scalableIconsServeEverySize()
|
||||
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)));
|
||||
// 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<int, QImage> 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<int> 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 : {22, 24, 32, 64})
|
||||
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 = meanVisibleColor(pixmap.toImage());
|
||||
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 (theme == "qet")
|
||||
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())));
|
||||
@@ -252,6 +346,136 @@ void tst_qeticons::scalableIconsServeEverySize()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
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<QPair<QString, int>> folders = {
|
||||
{"16x16", 16}, {"22x22", 22}, {"scalable", 24}, {"32x32", 32},
|
||||
{"48x48", 48}, {"64x64", 64}, {"128x128", 128}};
|
||||
const QRegularExpression canvas(R"re(<svg\b[^>]*?\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<QPair<QString, int>> folders = {
|
||||
{"16x16", 16}, {"22x22", 22}, {"24x24", 24}, {"32x32", 32},
|
||||
{"48x48", 48}, {"64x64", 64}, {"128x128", 128}};
|
||||
const QRegularExpression canvas(R"re(<svg\b[^>]*?\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<QString>("theme");
|
||||
@@ -410,7 +634,7 @@ void tst_qeticons::coloredIconKeepsItsColorsOnHover()
|
||||
QApplication::setStyle(new QETStyle(QStyleFactory::create("Fusion")));
|
||||
QApplication::setPalette(QET::Palette::fusionLight());
|
||||
|
||||
const QIcon icon = QIcon::fromTheme("document-open"); // colored Oxygen art
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user