[Icons] Add a sticker icon treatment

The pre-#7595 add, remove and delete icons were filled shapes with a black
outline and no disc. A tint cannot bring that back, and the badge disc is a
different icon language, so add a third value to the existing style axis.

IconStyle::Sticker draws the sentinel glyph twice: a near-black rim at the
band width plus one rim on each side, then the tone pass clipped to the glyph
and filled with a vertical gradient. Both treatments keep the disabled
fallback to the line glyph.

The fill rule moves into theme_icon_fill: the semantic tones are tuned for
text, so a filled shape gives its tone a saturation floor, and a dark-scheme
tone is pulled down rather than up. IconTint::resolve() applies the same floor
to line glyphs, and the badge disc uses the same fill, so a line, a badge and
a sticker next to each other carry the same green or red instead of a pale
line beside a saturated glyph.

The badge's glyph ink follows the scheme: white on light-scheme tones, as in
the pre-#7595 art, and the saturated tone darkened to an outline-dark ink on
dark-scheme tones, where the shared saturated disc is light enough that white
would sit near 2.7:1. The new test pins both cases so the glyph cannot
silently flip.

resolveSvgStrokeWidth() moves the stroke-width rewrite into
libcockatrice_utility so the badge and the sticker share it; the badge's local
copy is gone.

The sticker is opted into at 17 call sites: increment, decrement and
remove_row in the deck editor, settings pages, token dialog, server dialogs
and replay/deck storage tabs. The visual database's clear-filter button keeps
its trash can (icons/delete) in the line treatment: that glyph is new in
#7595, but it is the clearer metaphor, and a sticker rim closes its lid gap
at toolbar sizes.

The preview generators also gain a semantic-tint sheet for #7775 and a
line/badge/sticker family strip, so the three PR descriptions can show the
same rendered colors.
This commit is contained in:
Lukas Brübach 2026-11-06 20:19:54 +01:00
parent dc2ab19147
commit 66196f6a60
25 changed files with 1027 additions and 127 deletions

View file

@ -2,6 +2,8 @@
#include "libcockatrice/utility/svg_recolor.h"
#include "theme_icon_badge.h"
#include "theme_icon_fill.h"
#include "theme_icon_sticker.h"
#include "theme_manager.h"
#include <QApplication>
@ -41,7 +43,11 @@ QColor IconTint::resolve(QIcon::Mode mode) const
if (mode == QIcon::Disabled) {
return qApp->palette().color(QPalette::Disabled, QPalette::WindowText);
}
const QColor color = themeManager->appColor(appRole);
// The three treatments paint the same tone: a line glyph, a badge disc
// and a sticker fill must not disagree on how saturated the role is.
// The fill treatments apply this floor themselves, so this only changes
// the line path, and only for the desaturated dark-scheme tones.
const QColor color = ThemeIconFill::saturated(themeManager->appColor(appRole));
if (mode != QIcon::Selected) {
return color;
}
@ -264,12 +270,14 @@ private:
{
ensureResolved();
// Badge is a shape treatment, so it only applies to sentinel art: an
// authored asset keeps its own drawing and follows the line/asset path.
// Disabled also drops back to the line glyph: a filled disc at full mass
// does not read as unavailable next to the thin line glyphs around it,
// and the tint already removes the semantic tone.
const bool badge = style == IconStyle::Badge && resolvedHasSentinel && mode != QIcon::Disabled;
// Badge and Sticker are shape treatments, so they only apply to sentinel
// art: an authored asset keeps its own drawing and follows the line/asset
// path. Disabled also drops back to the line glyph: a filled disc or
// sticker at full mass does not read as unavailable next to the thin line
// glyphs around it, and the tint already removes the semantic tone.
const bool treated = style != IconStyle::Line && resolvedHasSentinel && mode != QIcon::Disabled;
const bool badge = treated && style == IconStyle::Badge;
const bool sticker = treated && style == IconStyle::Sticker;
// Same cache for all 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 key spaces cannot collide. A
@ -280,6 +288,8 @@ private:
if (badge) {
variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' +
ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1();
} else if (sticker) {
variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1();
} else if (resolvedHasSentinel) {
variant = tone.name(QColor::HexRgb).toLatin1();
} else {
@ -296,6 +306,8 @@ private:
// Geometry is computed from the device-pixel size, and the ratio is
// attached below like every other path.
pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize);
} else if (sticker) {
pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize);
} else if (resolvedHasSentinel) {
pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio);
} else {
@ -347,12 +359,13 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const
}
const QColor tone = tint.resolve(QIcon::Normal);
if (style == IconStyle::Badge) {
if (style == IconStyle::Badge || style == IconStyle::Sticker) {
QSize canvas = size;
if (canvas.isEmpty()) {
canvas = QSvgRenderer(svg).defaultSize();
}
return ThemeIconBadge::renderBadge(svg, tone, canvas);
return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas)
: ThemeIconSticker::renderSticker(svg, tone, canvas);
}
return renderSvg(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), size, 1.0);

View file

@ -35,11 +35,16 @@
* that carries its own colors is never badged; it falls back to the asset path.
* Disabled icons also fall back to the line glyph, because a filled disc at full
* mass does not read as unavailable.
*
* Sticker reproduces the other half of the pre-#7595 set: the filled add, remove
* and delete glyphs, painted with a tone gradient and a dark outline and no
* disc. It shares the disabled fallback for the same reason.
*/
enum class IconStyle
{
Line, // the recolored glyph alone
Badge, // filled disc + dark ring + contrast glyph
Line, // the recolored glyph alone
Badge, // filled disc + dark ring + contrast glyph
Sticker, // filled glyph + dark outline, no disc
};
/**
@ -62,6 +67,9 @@ public:
* Disabled application colors resolve to the disabled foreground so an
* unavailable control does not keep signalling its semantic color. Selected
* palette colors keep the existing WindowText -> HighlightedText swap.
* Application colors pass through the filled treatments' saturation floor,
* so a line glyph, a badge disc and a sticker fill carry the same tone
* instead of a pale line beside a saturated shape.
*/
QColor resolve(QIcon::Mode mode) const;
@ -90,8 +98,8 @@ private:
* extension (for example `icons/search`), matching themePixmap().
* @param tint Color that replaces the sentinel: a palette role or a semantic
* application color. Defaults to WindowText.
* @param style Treatment of the glyph: a line, or the badge shape that keeps
* the pre-migration filled-art reading. Defaults to Line.
* @param style Treatment of the glyph: a line, or one of the filled treatments
* that keep the pre-migration art reading. Defaults to Line.
* @return An icon that follows the application palette.
*/
QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = IconStyle::Line);
@ -107,8 +115,9 @@ QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = I
* @param tint Color that replaces the sentinel: a palette role or a semantic
* application color. Defaults to WindowText.
* @param size Pixel size to render at; an empty size uses the SVG's intrinsic size.
* @param style Treatment of the glyph: a line, or a badge. A badge on a
* non-sentinel asset stays null, like the line path.
* @param style Treatment of the glyph: a line, or one of the filled treatments.
* A treatment on a non-sentinel asset stays null, like the line
* path.
* @return The recolored pixmap, or a null pixmap when the asset is not a
* sentinel-carrying SVG.
*/

View file

@ -1,6 +1,7 @@
#include "theme_icon_badge.h"
#include "libcockatrice/utility/svg_recolor.h"
#include "theme_icon_fill.h"
#include <QLinearGradient>
#include <QPainter>
@ -26,50 +27,21 @@ constexpr double GLYPH_DIAMETER_RATIO = 0.9;
// band near a tenth of the disc diameter, so the badge decides the glyph weight
// instead of inheriting the authored one.
constexpr qreal GLYPH_STROKE_WIDTH = 3.0;
constexpr int GRADIENT_TOP_LIGHTER = 112;
constexpr int GRADIENT_BOTTOM_DARKER = 104;
constexpr int RING_DARKER = 300;
constexpr int GLYPH_DARKER = 400;
} // namespace
/**
* @brief Gives the engraved glyph the weight the badge needs.
*
* Inheritance cannot do this: the authored `stroke-width` is tuned for a 24px
* line next to text, not for a mark sitting inside a 13px disc. Overriding every
* stroke in the glyph keeps the check, cross and arrow readable at the sizes the
* UI uses.
*
* Stays in bytes like resolveSvgColorSentinel(): the SVG is never decoded, so a
* theme asset that is not valid UTF-8 survives the rewrite.
*/
QByteArray withBadgeStrokeWeight(const QByteArray &svg)
namespace
{
constexpr QByteArrayView ATTRIBUTE = "stroke-width";
constexpr QByteArrayView EQUALS = "=";
constexpr QByteArrayView QUOTE = "\"";
const QByteArray value = QByteArray::number(GLYPH_STROKE_WIDTH, 'f', 1);
QByteArray resolved;
resolved.reserve(svg.size());
struct DiscFill
{
QColor top;
QColor bottom;
};
qsizetype cursor = 0;
while (true) {
const qsizetype start = svg.indexOf(ATTRIBUTE, cursor);
if (start < 0) {
resolved.append(svg.sliced(cursor));
return resolved;
}
const qsizetype equals = svg.indexOf(EQUALS, start + ATTRIBUTE.size());
const qsizetype quote = equals < 0 ? -1 : svg.indexOf(QUOTE, equals);
const qsizetype close = quote < 0 ? -1 : svg.indexOf(QUOTE, quote + 1);
if (close < 0) {
// Not an attribute we understand: leave the rest untouched.
resolved.append(svg.sliced(cursor));
return resolved;
}
resolved.append(svg.sliced(cursor, quote + 1 - cursor));
resolved.append(value);
cursor = close;
}
DiscFill discFill(const QColor &tone)
{
return {ThemeIconFill::topColor(tone), ThemeIconFill::bottomColor(tone)};
}
} // namespace
@ -93,13 +65,14 @@ double contrastRatio(const QColor &first, const QColor &second)
QColor glyphColor(const QColor &tone)
{
// Pick the more legible of the two inks rather than a literal: white keeps
// the old light-scheme look, and the tone darkened to an outline-dark ink
// wins on the light tones a dark scheme uses, where white drops under 3:1.
// Deriving it from the tone keeps themes in control and leaves no hardcoded
// hex in the paint path.
const QColor darkInk = tone.darker(GLYPH_DARKER);
return contrastRatio(tone, QColor(Qt::white)) >= contrastRatio(tone, darkInk) ? QColor(Qt::white) : darkInk;
// The badge carries the ink the pre-sticker badge did: white on the light
// scheme, and the tone darkened to an outline-dark ink on the dark scheme.
// The shared saturated disc is light enough there that white sits near
// 2.7:1 at the top, so the dark ink is the deliberate choice rather than a
// contrast measurement that a palette tweak could flip. Deriving it from
// the tone keeps themes in control.
return ThemeIconFill::isDarkSchemeTone(tone) ? ThemeIconFill::saturated(tone).darker(GLYPH_DARKER)
: QColor(Qt::white);
}
QSize glyphSize(const QSize &pixelSize)
@ -134,8 +107,9 @@ QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixe
painter.setRenderHint(QPainter::Antialiasing, true);
QLinearGradient gradient(discRect.topLeft(), discRect.bottomLeft());
gradient.setColorAt(0.0, tone.lighter(GRADIENT_TOP_LIGHTER));
gradient.setColorAt(1.0, tone.darker(GRADIENT_BOTTOM_DARKER));
const DiscFill fill = discFill(tone);
gradient.setColorAt(0.0, fill.top);
gradient.setColorAt(1.0, fill.bottom);
painter.setPen(Qt::NoPen);
painter.setBrush(gradient);
painter.drawEllipse(discRect);
@ -143,12 +117,12 @@ QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixe
// The ring closes the disc: its outer edge touches the canvas, its inner
// edge is the disc boundary, so the two meet without a seam.
painter.setBrush(Qt::NoBrush);
painter.setPen(QPen(tone.darker(RING_DARKER), ringWidth));
painter.setPen(QPen(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth));
const qreal ringRadius = (side - ringWidth) / 2.0;
painter.drawEllipse(discRect.center(), ringRadius, ringRadius);
const QByteArray glyphSvg =
withBadgeStrokeWeight(resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()));
const QByteArray glyphSvg = resolveSvgStrokeWidth(
resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH);
QSvgRenderer renderer(glyphSvg);
if (renderer.isValid()) {
const QSize size = glyphSize(pixelSize);

View file

@ -24,9 +24,9 @@
* The trade-off is fidelity against contrast. A badge is a small picture: it
* survives a busy toolbar and a tinted row better than a hairline, and it can
* carry a light or dark glyph independent of the surrounding text. In exchange
* it costs more pixels of attention per control, and its glyph color is decided
* from the disc it sits on rather than from the window, so it is deliberately
* not the palette's text color.
* it costs more pixels of attention per control, and its glyph color follows the
* scheme it is drawn for rather than the window's text color, so it is
* deliberately not the palette's foreground.
*
* Everything here works in device pixels. The icon engine renders at the
* device-pixel size it already computed and sets the pixmap's device pixel ratio
@ -37,8 +37,7 @@ namespace ThemeIconBadge
/**
* @brief WCAG relative luminance, used to test a color against a background.
*
* Shared by the badge glyph choice and by IconTint::resolve()'s selected-row
* contrast test, so both use the same definition of "legible".
* Used by IconTint::resolve()'s selected-row contrast test.
*/
double relativeLuminance(const QColor &color);
@ -48,10 +47,11 @@ double contrastRatio(const QColor &first, const QColor &second);
/**
* @brief Glyph color for a badge disc.
*
* The more legible of white and the tone darkened to an outline-dark ink. White
* keeps the old light-scheme look; on a dark scheme the semantic tone is a light
* green or red, where white sits near 1.7:1 and disappears, so the dark ink
* wins instead. Both candidates derive from the theme, so no literal is needed.
* White on the light scheme, the saturated tone darkened to an outline-dark ink
* on the dark scheme. The dark-scheme tones fill the disc light enough that
* white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink
* the badge had before the filled treatments shared a fill. The dark ink is
* derived from the tone, so the theme keeps control of both ends.
*/
QColor glyphColor(const QColor &tone);
@ -61,10 +61,11 @@ QSize glyphSize(const QSize &pixelSize);
/**
* @brief Renders a sentinel-carrying SVG as a badge.
*
* The disc fills the square canvas minus the ring, the ring darkens the tone,
* and the sentinel is resolved to the contrast glyph. The SVG is scaled to
* nearly the whole disc, because the sentinel paths are padded inside their
* viewBox and a smaller box loses the stroke at 16px.
* The disc fills the square canvas minus the ring with the same gradient the
* sticker fill uses, the ring darkens the tone, and the sentinel is resolved to
* the contrast glyph. The SVG is scaled to nearly the whole disc, because the
* sentinel paths are padded inside their viewBox and a smaller box loses the
* stroke at 16px.
*
* This treatment reproduces the pre-#7595 ready/not-ready art, which was a
* filled disc. It is not a generic replacement for every semantic glyph: the

View file

@ -0,0 +1,61 @@
#include "theme_icon_fill.h"
#include <QtGlobal>
namespace
{
// Semantic tones are tuned for text. The dark-scheme ones are deliberately
// desaturated, which reads washed out when the same color fills a whole shape,
// so the fill gives its tone a saturation floor.
constexpr int MIN_SATURATION = 170;
// A tone this light belongs to the dark scheme; filling a glyph with it at full
// value is what looks pale, so its gradient is pulled down instead of up.
constexpr int LIGHT_TONE_VALUE = 200;
constexpr int MIN_VALUE = 200;
constexpr int GRADIENT_BOTTOM_DARKER = 140;
constexpr int DARK_SCHEME_TOP_DARKER = 120;
constexpr int DARK_SCHEME_BOTTOM_DARKER = 170;
} // namespace
namespace
{
/** @brief Raises a tone's saturation, and optionally its value, for a filled shape. */
QColor boost(const QColor &color, int minValue)
{
if (color.hue() < 0 || (color.hsvSaturation() >= MIN_SATURATION && color.value() >= minValue)) {
return color;
}
QColor result = color;
result.setHsv(color.hue(), qMax(color.hsvSaturation(), MIN_SATURATION), qMax(color.value(), minValue));
// Return RGB so callers comparing or caching the tone do not see a spec
// change for an unchanged color.
return result.toRgb();
}
} // namespace
namespace ThemeIconFill
{
QColor saturated(const QColor &tone)
{
return boost(tone, 0);
}
bool isDarkSchemeTone(const QColor &tone)
{
return tone.value() >= LIGHT_TONE_VALUE;
}
QColor topColor(const QColor &tone)
{
if (isDarkSchemeTone(tone)) {
return boost(tone, 0).darker(DARK_SCHEME_TOP_DARKER);
}
return boost(tone, MIN_VALUE);
}
QColor bottomColor(const QColor &tone)
{
const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER;
return boost(tone, 0).darker(darker);
}
} // namespace ThemeIconFill

View file

@ -0,0 +1,64 @@
/**
* @file theme_icon_fill.h
* @ingroup Interface
*/
#ifndef THEME_ICON_FILL_H
#define THEME_ICON_FILL_H
#include <QColor>
/**
* @brief Picks the two colors of a filled treatment's vertical gradient.
*
* The pre-#7595 icons were filled with a lighter top over a darker bottom, and
* the semantic tones are tuned for text rather than for filling a whole shape:
* the dark-scheme tones especially are deliberately desaturated, which reads
* washed out once a disc or a glyph carries them. The badge and the sticker
* therefore share one fill rule, so the two sit next to each other with the same
* green or red.
*
* The rule needs no scheme switch of its own: a tone whose value is already high
* is a dark-scheme tone, and its gradient is pulled down instead of up, because
* filling a shape with a tone that light is what reads pale.
*/
namespace ThemeIconFill
{
/**
* @brief Returns the tone with a saturation floor, for a filled shape.
*
* The hue and value are left alone, so a light-scheme tone stays as dark as the
* line glyph next to it, and an achromatic tone stays gray.
*
* @param tone Glyph color the treatment is rendered with.
* @return The tone, saturated enough to carry a whole shape.
*/
QColor saturated(const QColor &tone);
/**
* @brief True when @p tone is a light tone, as the dark scheme's palette is.
*
* The fill branches on this: a tone this light is darkened for a filled shape,
* and the badge keeps its darkened ink on it instead of the light scheme's
* white glyph.
*
* @param tone Glyph color the treatment is rendered with.
*/
bool isDarkSchemeTone(const QColor &tone);
/**
* @brief Returns the top color of the fill gradient.
* @param tone Glyph color the treatment is rendered with.
* @return The lighter end of the gradient.
*/
QColor topColor(const QColor &tone);
/**
* @brief Returns the bottom color of the fill gradient.
* @param tone Glyph color the treatment is rendered with.
* @return The darker end of the gradient.
*/
QColor bottomColor(const QColor &tone);
} // namespace ThemeIconFill
#endif // THEME_ICON_FILL_H

View file

@ -0,0 +1,87 @@
#include "theme_icon_sticker.h"
#include "libcockatrice/utility/svg_recolor.h"
#include "theme_icon_fill.h"
#include <QLinearGradient>
#include <QPainter>
#include <QPointF>
#include <QRectF>
#include <QSvgRenderer>
#include <QtGlobal>
namespace
{
// The pre-#7595 add/remove art filled about 80% of the canvas: the sentinel
// paths are padded inside their 24-unit viewBox, so the drawing box has to be
// slightly larger than the canvas for the ink to reach that far.
constexpr qreal STICKER_SCALE = 1.06;
// Old arm thickness was about a fifth of the canvas, with a rim near 5%. In the
// 24-unit viewBox that is a 4.5-unit band with a 0.9-unit rim on each side.
constexpr qreal STICKER_STROKE_WIDTH = 4.5;
constexpr qreal STICKER_OUTLINE_WIDTH = 0.9;
// The old art's outline was black. Darkening by 300 reads as black on a light
// scheme (the shipped tones are already dark there) while staying a visible dark
// rim on a dark one, where a neutral black would vanish into the window.
constexpr int OUTLINE_DARKER = 300;
} // namespace
namespace ThemeIconSticker
{
QColor outlineColor(const QColor &tone)
{
return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER);
}
QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize)
{
if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) {
return {};
}
QPixmap pixmap(pixelSize);
pixmap.fill(Qt::transparent);
const qreal side = qMin(pixelSize.width(), pixelSize.height());
const qreal box = side * STICKER_SCALE;
const QRectF glyphRect(QPointF((pixelSize.width() - box) / 2.0, (pixelSize.height() - box) / 2.0),
QSizeF(box, box));
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
// Outline first, at the band width plus one rim on each side; the fill pass
// then covers the middle and leaves only the rim visible.
const QByteArray outlineSvg =
resolveSvgStrokeWidth(resolveSvgColorSentinel(svg, outlineColor(tone).name(QColor::HexRgb).toLatin1()),
STICKER_STROKE_WIDTH + 2.0 * STICKER_OUTLINE_WIDTH);
QSvgRenderer outlineRenderer(outlineSvg);
if (outlineRenderer.isValid()) {
outlineRenderer.render(&painter, glyphRect);
}
// The fill needs its own pass so the gradient can be clipped to the glyph:
// painting it onto the composited pixmap would tint the outline as well.
QPixmap fillPixmap(pixelSize);
fillPixmap.fill(Qt::transparent);
{
QPainter fillPainter(&fillPixmap);
fillPainter.setRenderHint(QPainter::Antialiasing, true);
const QByteArray fillSvg = resolveSvgStrokeWidth(
resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), STICKER_STROKE_WIDTH);
QSvgRenderer fillRenderer(fillSvg);
if (fillRenderer.isValid()) {
fillRenderer.render(&fillPainter, glyphRect);
fillPainter.setCompositionMode(QPainter::CompositionMode_SourceIn);
QLinearGradient gradient(glyphRect.topLeft(), glyphRect.bottomLeft());
gradient.setColorAt(0.0, ThemeIconFill::topColor(tone));
gradient.setColorAt(1.0, ThemeIconFill::bottomColor(tone));
fillPainter.fillRect(QRectF(QPointF(0, 0), QSizeF(pixelSize)), gradient);
}
}
painter.drawPixmap(0, 0, fillPixmap);
return pixmap;
}
} // namespace ThemeIconSticker

View file

@ -0,0 +1,62 @@
/**
* @file theme_icon_sticker.h
* @ingroup Interface
*/
#ifndef THEME_ICON_STICKER_H
#define THEME_ICON_STICKER_H
#include <QByteArray>
#include <QColor>
#include <QPixmap>
#include <QSize>
/**
* @brief Paints the sticker treatment of a `currentColor` glyph.
*
* The pre-#7595 add, remove and delete icons were filled shapes with a black
* outline, not discs. A tint alone cannot bring that back: it produces a
* hairline, and a badge disc would be a different icon language. The sticker
* treatment rebuilds the look from the same sentinel art by drawing the glyph
* twice, once with a dark outline and once with a vertical tone gradient on top.
*
* The trade-off is weight against crispness. A sticker is louder than a line,
* which is what an add or a destructive action wants, but it needs a size where
* the tone band is at least two device pixels wide. Callers opt in; navigation
* and menus keep the line treatment.
*
* Everything here works in device pixels, like the badge treatment: the engine
* renders at the size it already computed and attaches the device pixel ratio
* afterwards.
*/
namespace ThemeIconSticker
{
/**
* @brief Returns the dark rim color the sticker is painted with.
*
* The old art had a black outline. Deriving it from the tone reads as black on a
* light scheme without a literal, and stays visible on a dark one, where a pure
* black rim would disappear into the window.
*
* @param tone Glyph color the sticker is rendered with.
* @return A much darker version of the tone.
*/
QColor outlineColor(const QColor &tone);
/**
* @brief Renders a sentinel-carrying SVG as a filled glyph with a black outline.
*
* The glyph is drawn larger than the canvas so the padded sentinel paths fill
* the icon the way the old art did, with a vertical tone gradient as the fill
* and a near-black rim. Authored art is never drawn through this path.
*
* @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally.
* @param tone Glyph color, already resolved for the icon mode.
* @param pixelSize Canvas size in device pixels; a non-square canvas centers
* the glyph and leaves the remaining area transparent.
* @return The composited sticker, or a null pixmap for empty or invalid input.
*/
QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize);
} // namespace ThemeIconSticker
#endif // THEME_ICON_STICKER_H

View file

@ -248,19 +248,19 @@ void DeckEditorDeckDockWidget::createDeckDock()
&DeckEditorDeckDockWidget::applyActiveGroupCriteria);
aIncrement = new QAction(QString(), this);
aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive));
aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker));
connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection);
auto *tbIncrement = new QToolButton(this);
tbIncrement->setDefaultAction(aIncrement);
aDecrement = new QAction(QString(), this);
aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection);
auto *tbDecrement = new QToolButton(this);
tbDecrement->setDefaultAction(aDecrement);
aRemoveCard = new QAction(QString(), this);
aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard);
auto *tbRemoveCard = new QToolButton(this);
tbRemoveCard->setDefaultAction(aRemoveCard);

View file

@ -64,7 +64,7 @@ void DeckEditorFilterDockWidget::createFiltersDock()
connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter);
aClearFilterOne = new QAction(QString(), this);
aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne);
aClearFilterAll = new QAction(QString(), this);

View file

@ -38,7 +38,8 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
btnDeleteServer->setIcon(
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);

View file

@ -108,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul
&DlgEditTokens::tokenSelectionChanged);
QAction *aAddToken = new QAction(tr("Add token"), this);
aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive));
aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker));
connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken);
QAction *aRemoveToken = new QAction(tr("Remove token"), this);
aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken);
auto *databaseToolBar = new QToolBar;

View file

@ -34,7 +34,8 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
btnDeleteServer->setIcon(
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);

View file

@ -80,7 +80,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
}
aAdd = new QAction(this);
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive));
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker));
connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL);
aEdit = new QAction(this);
@ -88,7 +88,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL);
aRemove = new QAction(this);
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL);
aRateLimit = new QAction(this);

View file

@ -150,7 +150,7 @@ MessagesSettingsPage::MessagesSettingsPage()
}
aAdd = new QAction(this);
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive));
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker));
connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd);
aEdit = new QAction(this);
@ -158,7 +158,7 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit);
aRemove = new QAction(this);
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove);
auto *messageToolBar = new QToolBar;

View file

@ -199,7 +199,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder);
aDeleteLocalDeck = new QAction(this);
aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
aDeleteLocalDeck->setIcon(
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck);
aOpenDecksFolder = new QAction(this);
@ -217,7 +218,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder);
aDeleteRemoteDeck = new QAction(this);
aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
aDeleteRemoteDeck->setIcon(
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck);
aShareDecks = new QAction(this);

View file

@ -155,7 +155,8 @@ QGroupBox *TabReplays::createLeftLayout()
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder);
aDeleteLocalReplay = new QAction(this);
aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
aDeleteLocalReplay->setIcon(
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay);
aOpenReplaysFolder = new QAction(this);
@ -217,7 +218,8 @@ QGroupBox *TabReplays::createRightLayout()
aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock")));
connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay);
aDeleteRemoteReplay = new QAction(this);
aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
aDeleteRemoteReplay->setIcon(
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay);
aGetReplayCode = new QAction(this);
aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share")));