Files
qelectrotech-source-mirror/sources/qetstyle.cpp
T
Jeff Patterson 4e43cde063 Give icons a hover state through a proxy style
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.
2026-09-19 12:26:06 -05:00

181 lines
6.2 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 "qetstyle.h"
#include <QApplication>
#include <QImage>
#include <QPainter>
#include <QPixmap>
#include <QStyleOption>
#include <QWidget>
#include "qetpalette.h"
/**
@brief QETStyle::QETStyle
@param base the style to wrap; this object takes ownership of it.
*/
QETStyle::QETStyle(QStyle *base) :
QProxyStyle(base)
{
setObjectName(base ? base->objectName() : QString());
}
/**
@brief QETStyle::isLineArt
The same rule misc/make_icon_themes.py uses to pick the icons that
get a dark variant: an icon is line art when fewer than 20% of its
visible pixels are saturated.
*/
bool QETStyle::isLineArt(const QImage &image)
{
const QImage source = image.convertToFormat(QImage::Format_ARGB32);
int visible = 0;
int saturated = 0;
for (int y = 0; y < source.height(); ++y)
{
const QRgb *line = reinterpret_cast<const QRgb *>(source.constScanLine(y));
for (int x = 0; x < source.width(); ++x)
{
const QRgb pixel = line[x];
if (qAlpha(pixel) <= 64)
continue;
++visible;
const QColor color(pixel);
if (color.hslSaturationF() > 0.25 && color.value() > 60)
++saturated;
}
}
return visible > 0 && saturated < visible * 0.20;
}
/**
@brief QETStyle::hoverColor
The palette's highlight color is the accent users already know from
selections. On a dark palette it is too dark to read on a hovered
button face, so it is lightened, a step at a time, until it reaches
3:1 (WCAG 1.4.11) against the Light role: Fusion paints a hovered
auto-raise button with a gradient that runs from Button up to about
that color, and the icon has to read on the lightest part of it.
*/
QColor QETStyle::hoverColor(const QPalette &palette)
{
const QColor face = palette.color(QPalette::Active, QPalette::Light);
QColor ink = palette.color(QPalette::Active, QPalette::Highlight);
// 3.5 rather than 3.0: the top of Fusion's hover gradient is a shade
// lighter than the Light role, so the icon needs some headroom there.
for (int step = 0; step < 20 && QET::Palette::contrastRatio(ink, face) < 3.5; ++step)
ink = ink.lighter(110);
return ink;
}
/**
@brief QETStyle::tinted
@return pixmap with every pixel set to color, alpha kept.
*/
QPixmap QETStyle::tinted(const QPixmap &pixmap, const QColor &color)
{
QImage image = pixmap.toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
QPainter painter(&image);
painter.setCompositionMode(QPainter::CompositionMode_SourceIn);
painter.fillRect(image.rect(), color);
painter.end();
QPixmap result = QPixmap::fromImage(image);
result.setDevicePixelRatio(pixmap.devicePixelRatio());
return result;
}
/**
@brief QETStyle::menuIcon
An icon for a highlighted menu item: Normal as the original, Active
as the original in the HighlightedText color when it is line art.
Cached per source icon, size and device pixel ratio, since menus
repaint on every pointer move.
*/
QIcon QETStyle::menuIcon(const QIcon &icon, const QPalette &palette, int size, qreal dpr) const
{
const QString key = QString("%1/%2/%3/%4")
.arg(icon.cacheKey()).arg(size).arg(dpr)
.arg(palette.color(QPalette::Active, QPalette::HighlightedText).name());
auto it = menu_icons_.constFind(key);
if (it != menu_icons_.constEnd())
return *it;
const QPixmap normal = icon.pixmap(QSize(size, size), dpr, QIcon::Normal);
QIcon result;
result.addPixmap(normal, QIcon::Normal);
result.addPixmap(isLineArt(normal.toImage())
? tinted(normal, palette.color(QPalette::Active, QPalette::HighlightedText))
: normal,
QIcon::Active);
result.addPixmap(icon.pixmap(QSize(size, size), dpr, QIcon::Disabled), QIcon::Disabled);
menu_icons_.insert(key, result);
return result;
}
/**
@brief QETStyle::drawControl
CE_MenuItem with an icon and a highlight: swap the icon for menuIcon()
so the base style's QIcon::Active request gets an icon that reads on
the highlight bar. Everything else goes to the base style.
*/
void QETStyle::drawControl(ControlElement element,
const QStyleOption *option,
QPainter *painter,
const QWidget *widget) const
{
if (element == CE_MenuItem)
{
const auto *item = qstyleoption_cast<const QStyleOptionMenuItem *>(option);
if (item && !item->icon.isNull()
&& (item->state & State_Selected) && (item->state & State_Enabled))
{
QStyleOptionMenuItem copy = *item;
const int size = pixelMetric(PM_SmallIconSize, option, widget);
const qreal dpr = widget ? widget->devicePixelRatio()
: QApplication::instance() ? qApp->devicePixelRatio() : 1.0;
copy.icon = menuIcon(item->icon, item->palette, size, dpr);
QProxyStyle::drawControl(element, &copy, painter, widget);
return;
}
}
QProxyStyle::drawControl(element, option, painter, widget);
}
/**
@brief QETStyle::generatedIconPixmap
QIcon::Active: a line-art icon comes back tinted in hoverColor(),
alpha kept, so the hovered icon differs from the normal one on both
light and dark palettes. Colored icons and every
other mode go to the base style.
*/
QPixmap QETStyle::generatedIconPixmap(QIcon::Mode mode,
const QPixmap &pixmap,
const QStyleOption *option) const
{
if (mode != QIcon::Active || pixmap.isNull())
return QProxyStyle::generatedIconPixmap(mode, pixmap, option);
if (!isLineArt(pixmap.toImage()))
return QProxyStyle::generatedIconPixmap(mode, pixmap, option);
const QPalette palette = option ? option->palette : QApplication::palette();
return tinted(pixmap, hoverColor(palette));
}