mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-02 16:44:16 +02:00
789 lines
32 KiB
C++
789 lines
32 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 <QRegularExpression>
|
|
#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 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<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/, 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<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 : 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<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");
|
|
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;
|
|
// 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<QPalette>("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"
|