Compare commits

..

7 commits

Author SHA1 Message Date
Lukas Brübach
b6b6f1ea5d [Tests] Drop the empty icon baseline
The legacy icon set is fully paid down, so the ratchet had nothing left to
ratchet: BaselineIsWellFormed required the baseline to be empty, which made
the baseline file, the stale-entry diff and the entry-format regex
unreachable. The rules are enforced directly now.

- AssetsFollowTheRules fails on any violation of collectViolations().
- tests/icon_assets/icon-baseline.txt is gone with its reader.
2026-11-02 11:36:51 +01:00
Lukas Brübach
51e42a50ca [Icons] Add an icon asset ratchet test
The icon rules were enforced by review only, so the next Inkscape export could
reintroduce a baked fill or a physical size without anyone noticing.

Add a test that rejects non-24 viewBoxes, physical sizes, editor metadata,
hardcoded paints, missing tokens, color-coded filenames, and rasters. Keep the
legacy baseline empty so the ratchet can only tighten.
2026-11-02 11:36:51 +01:00
Lukas Brübach
31fb173881 [Icons] Render lock and dropdown glyphs through the icon engine
Both generators rendered the sentinel SVG to a fixed height x height pixmap
at DPR 1. The pre-migration path rendered larger and let QIcon scale it, so
on a 2x display the games-list lock (13px) and the banner dropdown arrow
(24px) came back crisp; the new path hands the widget a DPR-1 bitmap that
gets upscaled and blurred.

- The games list asks the themed engine for `icons/lock` directly, so the
  item view paints it at the device pixel ratio and the lock also follows
  HighlightedText on a selected row.
- The dropdown goes through `loadThemedIcon(...).pixmap(size, dpr)`, which
  keeps the glyph's aspect ratio and renders at the requested ratio.
- LockPixmapGenerator has no consumer left, so it and its cache are gone.
2026-11-02 11:36:03 +01:00
Lukas Brübach
c2ecd58e99 [Tests] Keep the icon render test QApplication alive until exit
Creating it as a function-local static tears it down before Qt's library statics
at exit, and libQt6Network then runs QObject destructors against freed memory —
a SEGV the sanitizer leg reports after the tests have already passed. Leak the
application instead, the usual pattern for a test binary.
2026-11-02 11:36:03 +01:00
Lukas Brübach
7e71d424f1 [Icons] Migrate UI glyphs to palette-token SVGs
Runtime inversion is not a theming system: it rotates hue, breaks duotone art
and mishandles alpha, and a QPixmap snapshot cannot follow a palette change.
Redraw the top-level UI glyph set on a 24x24 grid with `currentColor` as the
paint token, drop Inkscape/Sodipodi cruft, and delete the icons whose metaphor
was duplicated (cogwheel/gear -> settings, scale_balanced -> scales,
update.png -> reload).

Load the glyphs through `loadThemedIcon` so the engine resolves the token from
the palette on every request, and let `themePixmap` use the same resolution for
one-shot pixmaps. `loadColorAdjustedPixmap` is gone; every caller now keeps a
QIcon, which `SettingsButtonWidget::setButtonIcon` accepts instead of a QPixmap.

Content art (mana, counters, countries) keeps its authored colors.

Add a render test covering aspect ratio, palette flips, icon modes and
high-DPI pixmaps.
2026-11-02 11:36:03 +01:00
Lukas Brübach
95e5cda296 [Icons] Add palette-aware themed icon engine
A QPixmap snapshot cannot follow palette changes, so expose a QIcon whose
engine resolves the `currentColor` sentinel from the live palette on every
pixmap request. Icons stay legible across light/dark switches without
recreating actions or buttons.

- Resolve the asset, its bytes and its intrinsic size once, and drop them on
  ThemeManager::themeChanged/paletteChanged. A repaint does no file I/O and
  no theme.cfg parse; the palette color stays part of the render cache key.
- Centre the glyph on QRectF(rect).center(): QRect::center() floors and uses
  right = left + width - 1, which shifted every 16x16 item-view glyph up and
  left by a logical pixel and clipped its last row and column.
- Take a raster override's intrinsic size from QImageReader instead of the
  24x24 SVG default, so a non-square override reports the box it draws.
- Set the device pixel ratio before inserting into the render cache, so a
  cache hit no longer detaches the pixmap on every paint.
2026-11-02 11:35:14 +01:00
Lukas Brübach
42a0352477 [Icons] Add SVG currentColor sentinel resolver
Qt's SVG renderer has no CSS cascade, so a `currentColor` fill has to be
substituted before the bytes reach QSvgRenderer.

- Match and replace on bytes: both the sentinel and the replacement are
  ASCII, so the document never goes through a UTF-16 round trip, and files
  that are not valid UTF-8 survive unchanged.
- Document that the match is a plain case-insensitive substring, so an
  identifier that merely starts with the sentinel gets rewritten too.
2026-11-02 10:42:53 +01:00
4 changed files with 33 additions and 24 deletions

View file

@ -85,9 +85,9 @@ bool hasSentinel(const QByteArray &svg)
return !svg.isEmpty() && QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive);
}
QString cacheKey(const QString &path, const QByteArray &color, const QSize &size, qreal devicePixelRatio)
QString cacheKey(const QString &path, const QByteArray &variant, const QSize &size, qreal devicePixelRatio)
{
return path + QLatin1Char('|') + QString::fromLatin1(color) + QLatin1Char('|') + QString::number(size.width()) +
return path + QLatin1Char('|') + QString::fromLatin1(variant) + QLatin1Char('|') + QString::number(size.width()) +
QLatin1Char('x') + QString::number(size.height()) + QLatin1Char('@') + QString::number(devicePixelRatio);
}
@ -97,9 +97,11 @@ QString cacheKey(const QString &path, const QByteArray &color, const QSize &size
* Qt's SVG renderer has no ambient color, so the sentinel is substituted from
* the current palette on every pixmap request. Asset resolution, the file bytes
* and the intrinsic size are cached until the theme changes: a repaint then
* does no file I/O at all. The render cache is keyed by resolved path, palette
* color, pixel size and DPR, so a palette change produces new keys instead of
* reusing art built under the previous palette.
* does no file I/O at all. The render cache is keyed by resolved path and pixel
* size plus a variant: the palette color for sentinel SVGs, the icon mode for
* assets that carry their own colors. A palette change therefore produces new
* keys instead of reusing art built under the previous palette, and the
* non-sentinel path (raster overrides, plain SVGs) is cached like any other.
*/
class ThemeSvgIconEngine final : public QObject, public QIconEngine
{
@ -231,24 +233,26 @@ private:
QPixmap render(const QSize &pixelSize, qreal devicePixelRatio, QIcon::Mode mode) const
{
ensureResolved();
if (!resolvedHasSentinel) {
// Authored art carries its own colors: use Qt's normal loader so
// raster overrides and non-SVG assets keep working.
QPixmap pixmap = fromAsset(pixelSize, mode);
if (!pixmap.isNull()) {
pixmap.setDevicePixelRatio(devicePixelRatio);
}
return pixmap;
}
const QByteArray color = sentinelColor(mode, role);
const QString key = cacheKey(resolvedPath, color, pixelSize, devicePixelRatio);
// Same cache for both paths: sentinel art varies by palette color, art
// with its own colors varies by mode (Disabled is generated). A color
// variant is never a bare digit, so the two key spaces cannot collide.
const QByteArray variant =
resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast<int>(mode));
const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio);
const auto cached = cache.constFind(key);
if (cached != cache.constEnd()) {
return cached.value();
}
QPixmap pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, color), pixelSize, devicePixelRatio);
QPixmap pixmap;
if (resolvedHasSentinel) {
pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio);
} else {
// Authored art carries its own colors: use Qt's normal loader so
// raster overrides and non-SVG assets keep working.
pixmap = fromAsset(pixelSize, mode);
}
if (!pixmap.isNull()) {
// Set the ratio before caching: assigning it to a cached pixmap
// afterwards detaches (deep-copies) it on every paint.

View file

@ -86,7 +86,8 @@ const QString GamesModel::getGameCreatedString(const int secs)
}
GamesModel::GamesModel(const QMap<int, QString> &_rooms, const QMap<int, GameTypeMap> &_gameTypes, QObject *parent)
: QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes)
: QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes),
lockIcon(loadThemedIcon(QStringLiteral("icons/lock")))
{
}
@ -189,10 +190,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const
return result.join(", ");
}
case Qt::DecorationRole: {
// The themed icon follows the palette, so the lock also
// switches to HighlightedText on a selected row, and the
// item view paints it at the device pixel ratio.
return gameentry.with_password() ? loadThemedIcon(QStringLiteral("icons/lock")) : QVariant();
// One engine per model: it is palette- and theme-aware and
// invalidates itself, so a repaint must not build a new one.
return gameentry.with_password() ? lockIcon : QVariant();
case Qt::TextAlignmentRole:
return Qt::AlignLeft;
default:

View file

@ -5,6 +5,7 @@
#include "game_type_map.h"
#include <QAbstractTableModel>
#include <QIcon>
#include <QList>
#include <QMap>
#include <QModelIndex>
@ -32,6 +33,7 @@ private:
QList<ServerInfo_Game> gameList; /**< List of games currently displayed. */
QMap<int, QString> rooms; /**< Map of room IDs to room names. */
QMap<int, GameTypeMap> gameTypes; /**< Map of room IDs to available game types. */
QIcon lockIcon; /**< Themed lock for password-protected games. */
static const int NUM_COLS = 8; /**< Number of columns in the table. */

View file

@ -11,8 +11,11 @@ constexpr QByteArrayView SENTINEL = "currentColor";
/// its char overload cannot match a word.
qsizetype findSentinel(const QByteArray &bytes, qsizetype from)
{
for (qsizetype i = from; i + SENTINEL.size() <= bytes.size(); ++i) {
if (bytes.sliced(i, SENTINEL.size()).compare(SENTINEL, Qt::CaseInsensitive) == 0) {
// QByteArrayView::sliced() is a view, so the scan does not allocate a copy
// at every byte position (QByteArray::sliced() would).
const QByteArrayView view(bytes);
for (qsizetype i = from; i + SENTINEL.size() <= view.size(); ++i) {
if (view.sliced(i, SENTINEL.size()).compare(SENTINEL, Qt::CaseInsensitive) == 0) {
return i;
}
}