mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-29 05:44:14 +02:00
4e43cde063
Hovering a tool button changed only its frame, and on the dark palette Fusion's hover frame is too faint to notice, so nothing told the user which button was under the pointer (GitHub #870, PR 3 of the plan). QETStyle wraps the running style. For QIcon::Active it returns line-art icons tinted in the palette's highlight color, lightened until the tint reads at 3:1 on the Light role, which is the top of Fusion's hover gradient. Colored icons keep their colors, using the same line-art rule as misc/make_icon_themes.py. Every other mode goes to the base style. Fusion also asks for QIcon::Active for the icon of a highlighted menu item and paints it on the highlight bar, where the tint would vanish. The generated pixmap is cached per icon and cannot tell a menu from a toolbar, so drawControl(CE_MenuItem) hands the base style an icon whose Active pixmap is in the HighlightedText color instead. QETApp::initStyle() installs the proxy on every platform, keeping the base style's object name so the Fusion checks still match. tests/qttest/tst_qeticons: hovering changes the icon ink to the tint and stays at 3:1 on both palettes, on a raised button and on a checked one drawn sunken, where the frame gives no hover cue at all; a colored icon is unchanged on hover; a highlighted menu item's icon reads at 3:1 on the highlight bar. inkcontrast.h gains background() and ink() helpers for those checks.
389 lines
14 KiB
C++
389 lines
14 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 <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 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<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"
|