[Icons] Author icon emphasis colors as palette roles (#7789)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 45 release (push) Blocked by required conditions
Build Desktop / Fedora 45 debug (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions

* [Icons] Author icon emphasis colors as palette roles

The emphasis levels were computed from the accent at paint time, so a
theme could not vary them: every theme got the same derived strong and
soft tones, and a semantic hue was pushed toward the accent whether the
author wanted that or not.

Give each family three authored roles instead. Icon/IconStrong/IconSoft
carry the neutral glyph ink and Positive/Negative gain the same Strong
and Soft companions. Emphasis becomes a lookup inside the family, and a
caller that names a variant pins that level at every emphasis. The paint
path returns the authored color verbatim; only the filled treatments keep
their saturation floor, because carrying a disc or a fill is a treatment
decision.

Shipped palettes author the full ladder for both schemes; the palette
editor exposes the new rows and the generator writes them. System keeps
deriving its neutral ink from the platform palette, since it ships no
palette of its own.

* [Icons] Keep the authored emphasis in filled treatments

The saturation floor was added while the emphasis levels were still
computed: it rescued the pale dark-scheme tones once a whole shape
carried them. With the levels authored, the floor overrides the author
instead: a quiet level jumped to the secondary saturation as soon as a
sticker or a badge painted it, so the ladder only existed for line
glyphs.

The authored tone is now the top of the fill gradient and the treatment
only darkens the bottom for depth, so every treatment renders exactly the
level the palette stores. The badge glyph ink no longer guesses the
scheme from the tone's value: an authored quiet level is a dark color on
the dark scheme too, so the scheme is passed in from the surface palette.
The preview sheet renders every shipped palette theme, three glyphs per
family, and each row prints the authored tone and the darker bottom.

* [Icons] Order the emphasis ladder by chroma, not contrast

Contrast cannot order the ladder. On a dark surface the highest-contrast
tone is the palest one, so a 7:1 primary came out near-white while the
secondary kept the family's color; the ladder read inverted, and for the
shipped semantic greens and reds the primary level was already satisfied
by the secondary, so the two levels resolved to the same hex.

Emphasis is chromatic intensity now. Quiet recedes toward the surface at
the 3:1 non-text floor, secondary keeps the family color at the 4.5:1
text floor, and primary is the most chromatic tone the same floor allows.
Every level stays legible on the window and the button surface, and the
primary is a strictly more saturated member of its family on both
schemes. The reformatted palettes move the two upper levels onto the
shared floor and leave the quiet level alone.

* [Icons] Make the emphasis ladder structural on every theme

The chroma ladder landed with four P1s from review: reachContrast picked
its pole by background lightness instead of by what each pole can reach,
the generated neutral ink was solved on the window surface only, Fusion's
neutral family was still the green accent, and quiet sat below the text
floor while disabled reused the palette gray.

Pick the pole by achievable contrast, solve fallback and preview inks on
the harder of the window and button surfaces, re-author Fusion's neutral
trio in slate blue, and keep quiet on the shared text floor by muting its
saturation to 45% instead of blending it into the surface. Disabled is now
derived from the quiet ink, so it is structurally dimmer than every
enabled level on every theme.

Re-author all soft roles at a 4.60 target with a rounding margin, rebuild
the per-theme preview generator the rebase dropped, and add a Disabled row
to every treatment sheet.

* [Icons] Render preview glyphs at the sheet's device pixel ratio

The preview sheets paint on a 2x painter but rasterized each glyph at its
logical size, so every icon was upscaled on the way into the file and the
sheets read as blurry. Render each glyph at device size and tag the pixmap
with the ratio, so one source pixel lands on one file pixel.

* [Icons] Keep the System semantic ladder on the text floor

System authors no palette of its own, so its semantic family is fixed data
measured against whatever surfaces the platform provides. The ladder that
landed with the authored roles reused the old contrast-ordered values:
PositiveSoft and NegativeSoft sat at 2.76–3.90:1 on common light and dark
platform surfaces, and the strong level was less saturated than the
secondary, which is the inversion the chroma rule removes.

Re-author the six default semantic colors per scheme at the same 4.60
target the shipped themes use, against representative platform surfaces,
and mirror them in System's palette files. The new
DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces test holds the floor
and the saturation order there, and shippedPalette's early return now says
why System is outside the authored-palette assertions.

* [Icons] Apply the CI clang-format output to the new tests

* [Icons] Adapt the treatment preview to the authored roles

The preview file is inherited from the treatment PR. Here the palette already
authors the fill tone and the badge takes an explicit scheme, so the generator
stops re-saturating the role and passes the scheme the sheet renders.

* [Icons] Author neutral System icon ink

The System theme derived icon ink from the platform palette, which rendered blue under the default palette. Author Icon/IconStrong/IconSoft explicitly: a black ladder in light mode, a white ladder in dark mode. Pin authorship, achromaticity and ordering in the render test.

* Address review findings
This commit is contained in:
BruebachL 2026-11-08 19:09:02 +01:00 • committed by GitHub
parent 3e2884e6e1
commit c7e8422808
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1448 additions and 417 deletions

View file

@ -1,6 +1,7 @@
#include "palette_generator.h"
#include "../theme_config.h"
#include "../theme_icon_ink.h"
#include <QColor>
#include <QMap>
@ -167,10 +168,21 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
// the lightened, desaturated companion used for button-gradient highlights.
cfg.appColors[AppColor::AccentStrong] = hl;
cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0));
// Neutral icon ink follows the generated accent at the same three emphasis
// levels a hand-authored theme stores, so the preview matches what the icon
// engine renders before the palette is saved. The harder of the two surfaces
// is the one the floor has to be solved against.
const QColor iconSurface = ThemeIconInk::harderSurface(bg_win, bg_btn, winText);
const QColor iconInk = ThemeIconInk::glyphInk(winText, iconSurface, hl);
cfg.appColors[AppColor::Icon] = iconInk;
cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary);
cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet);
// Semantic roles stay independent of the accent: a generated palette must
// still paint add/ready green and remove/not-ready red.
cfg.appColors[AppColor::Positive] = defaultAppColor(AppColor::Positive, dark);
cfg.appColors[AppColor::Negative] = defaultAppColor(AppColor::Negative, dark);
for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft,
AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) {
cfg.appColors[role] = defaultAppColor(role, dark);
}
// BrightText
QColor bright;

View file

@ -65,10 +65,19 @@ static const QMap<AppColor::Role, const char *> APP_ROLE_DESCRIPTIONS = {
QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")},
{AppColor::AccentSoft,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")},
{AppColor::Icon, QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink at the default action weight")},
{AppColor::IconStrong,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for the one action a surface exists for")},
{AppColor::IconSoft,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for a glyph that labels rather than acts")},
{AppColor::Positive,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")},
{AppColor::PositiveStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at primary emphasis")},
{AppColor::PositiveSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at quiet emphasis")},
{AppColor::Negative,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")},
{AppColor::NegativeStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at primary emphasis")},
{AppColor::NegativeSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at quiet emphasis")},
};
PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)

View file

@ -458,12 +458,30 @@ QColor defaultAppColor(AppColor::Role role, bool dark)
switch (role) {
case AppColor::AccentStrong:
case AppColor::AccentSoft:
// Derived from the palette highlight by ThemeManager::appColor().
// The neutral icon family is palette-derived like the accent: a theme
// that leaves it unauthored (System, and every theme written before the
// roles existed) keeps the live platform palette's WindowText instead
// of pinning one scheme's ink.
case AppColor::Icon:
case AppColor::IconStrong:
case AppColor::IconSoft:
return {};
// The semantic family follows the same chroma ladder as the authored
// themes. A theme that authors none of it has no per-surface palette to
// measure against, so these are authored against the representative
// platform surfaces the System theme runs on.
case AppColor::Positive:
return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b"));
return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1a7d3a"));
case AppColor::PositiveStrong:
return QColor(dark ? QStringLiteral("#32c05c") : QStringLiteral("#007e28"));
case AppColor::PositiveSoft:
return QColor(dark ? QStringLiteral("#8ab196") : QStringLiteral("#4c7659"));
case AppColor::Negative:
return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e"));
case AppColor::NegativeStrong:
return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00"));
case AppColor::NegativeSoft:
return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c"));
}
return {};

View file

@ -13,6 +13,13 @@
// set. Stored in the same palette-<scheme>.toml under an [AppColors] section
// and editable from the palette editor, so theme authors can control colors
// beyond what Qt's palette can express.
//
// The icon families carry one role per emphasis level. Icon is the neutral
// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive
// and Negative follow the same pattern, so an emphasis never has to be computed
// from a hue at paint time: the theme authors all three levels. Naming a variant
// explicitly (`IconStrong`) pins that level; naming the family (`Icon`) lets the
// caller's emphasis choose.
namespace AppColor
{
Q_NAMESPACE
@ -20,8 +27,15 @@ enum Role
{
AccentStrong,
AccentSoft,
Icon,
IconStrong,
IconSoft,
Positive,
PositiveStrong,
PositiveSoft,
Negative,
NegativeStrong,
NegativeSoft,
};
Q_ENUM_NS(Role)
} // namespace AppColor
@ -47,9 +61,15 @@ Q_ENUM_NS(Role)
* @brief Fallback for an application color role when the active theme does not
* define one.
*
* Accent roles return an invalid QColor because ThemeManager derives them from
* the palette highlight. Semantic roles return an opaque default that suits the
* scheme, so themes written before the role existed still render it.
* Accent roles and the neutral Icon family return an invalid QColor because
* ThemeManager derives them from the palette: the accent from the highlight,
* the icon ink from the live WindowText held to the label floor. Semantic roles
* return an opaque default that suits the scheme, so themes written before the
* role existed still render it. The System theme deliberately leaves the
* neutral family unauthored: the palette file it picks cannot know the platform
* scheme, so a fixed ladder would be invisible on the other one. It derives
* neutral ink from the live platform palette instead of pinning one scheme's
* colors.
*/
QColor defaultAppColor(AppColor::Role role, bool dark);

View file

@ -2,7 +2,6 @@
#include "libcockatrice/utility/svg_recolor.h"
#include "theme_icon_badge.h"
#include "theme_icon_fill.h"
#include "theme_icon_ink.h"
#include "theme_icon_sticker.h"
#include "theme_manager.h"
@ -31,6 +30,74 @@
namespace
{
/**
* @brief The role an emphasis level selects inside a base role's family.
*
* The theme authors all three levels, so emphasis is a lookup instead of a
* color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an
* explicit request and keeps that role at every emphasis; only the base roles
* take part in the mapping. Accent roles have no neutral middle member, so they
* are always used as named.
*/
AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis)
{
switch (role) {
case AppColor::Icon:
return emphasis == IconEmphasis::Primary ? AppColor::IconStrong
: emphasis == IconEmphasis::Quiet ? AppColor::IconSoft
: AppColor::Icon;
case AppColor::Positive:
return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong
: emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft
: AppColor::Positive;
case AppColor::Negative:
return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong
: emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft
: AppColor::Negative;
default:
return role;
}
}
/**
* @brief The scheme the active application palette is painting for.
*
* The fill needs the surface scheme to choose the badge glyph ink, and it cannot
* read it from the tone: an authored quiet level is a dark color on the dark
* scheme too. The theme's resolved scheme is the same source the palette file
* and the default app colors are chosen from, so the fill follows it instead of
* re-deriving the scheme from the live Window lightness, which can disagree
* when a theme pins a scheme the platform palette does not match.
*/
ThemeIconFill::Scheme activeScheme()
{
if (themeManager != nullptr) {
return themeManager->isDarkModeActive() ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light;
}
// No manager (test binaries link the icon engine without the app): fall
// back to the live palette, which is the only scheme signal left.
const QPalette &palette = qApp->palette();
return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark
: ThemeIconFill::Scheme::Light;
}
/**
* @brief The ink a disabled glyph is painted with.
*
* A disabled icon used to be the palette's disabled text, a fixed gray whose
* contrast swings with the surface: on the darkest themes it out-reads the quiet
* level, so an enabled quiet glyph looks more disabled than a disabled one.
* Deriving it from the theme's own quiet ink keeps the order structural.
*/
QColor disabledGlyphInk(const QPalette &palette)
{
const QColor quiet = themeManager->appColor(AppColor::IconSoft);
const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Button), quiet);
return ThemeIconInk::disabledInk(quiet, surface);
}
/**
* @brief Reads an SVG through the `theme:` search path.
*/
@ -265,7 +332,7 @@ private:
QByteArray variant;
if (badge) {
variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' +
ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1();
ThemeIconBadge::glyphColor(tone, activeScheme()).name(QColor::HexRgb).toLatin1();
} else if (sticker) {
variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1();
} else if (resolvedHasSentinel) {
@ -283,7 +350,7 @@ private:
if (badge) {
// Geometry is computed from the device-pixel size, and the ratio is
// attached below like every other path.
pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize);
pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize, activeScheme());
} else if (sticker) {
pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize);
} else if (resolvedHasSentinel) {
@ -318,6 +385,7 @@ private:
mutable bool hasResolvedAsset = false;
mutable bool hasIntrinsicSize = false;
};
} // namespace
IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis)
@ -342,21 +410,18 @@ IconTint IconTint::quiet()
QColor IconTint::resolve(QIcon::Mode mode) const
{
const QPalette &palette = qApp->palette();
const QColor surface = palette.color(QPalette::Active, QPalette::Window);
const QColor accent = palette.color(QPalette::Active, QPalette::Highlight);
if (source == Source::AppColor) {
// A disabled control must not keep signalling its semantic color.
if (mode == QIcon::Disabled) {
return palette.color(QPalette::Disabled, QPalette::WindowText);
return disabledGlyphInk(palette);
}
// 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. The
// emphasis then decides how loud that tone is, without changing its hue.
const QColor color =
ThemeIconInk::forEmphasis(ThemeIconFill::saturated(themeManager->appColor(appRole)), surface, emphasis);
// The theme authors one color per emphasis level, so the paint path
// uses the resolved role verbatim. The filled treatments only shape the
// tone's value to give a disc or a fill depth; hue and saturation stay
// the author's.
const QColor color = themeManager->appColor(emphasisRole(appRole, emphasis));
if (mode != QIcon::Selected) {
return color;
}
@ -372,17 +437,14 @@ QColor IconTint::resolve(QIcon::Mode mode) const
const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active;
const QPalette::ColorRole effective =
mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole;
const QColor color = palette.color(group, effective);
// A neutral label role is inked as an icon, not as body text: it borrows a
// little accent chroma and is held to a contrast floor. Semantic roles and
// the selected-row swap keep the color the palette gave them.
if (group == QPalette::Active && (effective == QPalette::WindowText || effective == QPalette::Text)) {
// The primary action of a surface takes the accent itself; the other
// levels stay neutral. Emphasis then sets the contrast the glyph pays.
const QColor base = emphasis == IconEmphasis::Primary ? accent : ThemeIconInk::glyphInk(color, surface, accent);
return ThemeIconInk::forEmphasis(base, surface, emphasis);
// A neutral label role resolves to the theme's authored icon ink. A disabled
// one uses the dimmed quiet ink so it always sits below the quiet level;
// semantic roles keep the color the palette gave them.
if (effective == QPalette::WindowText || effective == QPalette::Text) {
return group == QPalette::Disabled ? disabledGlyphInk(palette)
: themeManager->appColor(emphasisRole(AppColor::Icon, emphasis));
}
return color;
return palette.color(group, effective);
}
QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style)
@ -407,7 +469,7 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const
if (canvas.isEmpty()) {
canvas = QSvgRenderer(svg).defaultSize();
}
return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas)
return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas, activeScheme())
: ThemeIconSticker::renderSticker(svg, tone, canvas);
}

View file

@ -47,18 +47,20 @@ enum class IconStyle
};
/**
* @brief Selects how much attention a glyph claims inside its surface.
* @brief Selects which authored level of a color family a glyph uses.
*
* Tint says which hue, style says how much shape, emphasis says how loud. The
* three are independent: a primary action can be a line glyph in the theme
* accent, and a quiet one can be a sticker in a dimmed tone.
* Tint says which family, style says how much shape, emphasis says how loud.
* The three are independent: a primary action can be a line glyph in the
* theme's primary ink, and a quiet one can be a sticker in that family's quiet
* level.
*
* Secondary is the default and the color every icon had before the axis
* existed, so existing call sites keep their look. Primary is for the one
* action a surface exists for: a neutral primary glyph takes the accent, and a
* tinted one is pushed to full contrast. Quiet is for glyphs that label rather
* than act, such as the magnifier inside a search field: legible, but never
* competing with the buttons next to it.
* Each family (neutral icon, positive, negative) stores one color per level, so
* emphasis is a lookup rather than a computation: the palette editor's rows are
* the whole source of truth. Secondary is the default and the color every icon
* had before the axis existed, so existing call sites keep their look. Primary
* is for the one action a surface exists for. Quiet is for glyphs that label
* rather than act, such as the magnifier inside a search field: legible, but
* never competing with the buttons next to it.
*/
enum class IconEmphasis
{
@ -82,7 +84,7 @@ public:
IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers
IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers
/** @brief A neutral glyph in the accent tone, for the surface's primary action. */
/** @brief A neutral glyph at the family's primary level, for the surface's primary action. */
static IconTint primary();
/** @brief A dimmed neutral glyph, for a control that labels rather than acts. */
@ -91,21 +93,26 @@ public:
/**
* @brief Resolves the concrete color for an icon mode.
*
* A disabled application color resolves to the disabled foreground, so an
* unavailable control does not keep its semantic color. A selected palette
* color keeps the WindowText -> HighlightedText swap. Application colors go
* through the saturation floor shared by the filled treatments, so a line
* glyph, a badge disc and a sticker fill carry the same tone.
* A disabled application color resolves to the derived disabled ink, the
* quiet level receded toward the surface, so an unavailable control does not
* keep its semantic color. A selected palette color keeps the
* WindowText -> HighlightedText swap.
*
* Application colors are returned verbatim: the theme author stores one
* value per emphasis level, so the paint path does not rewrite it. The
* filled treatments only shape that tone's value to give a shape depth; the
* hue and saturation stay the author's.
*
* Neutral label roles (WindowText, Text) resolve to icon ink rather than the
* raw palette color: ThemeIconInk::glyphInk borrows a little accent chroma
* and holds the glyph to a contrast floor, because a 2px glyph in body-text
* ink reads thinner than the text beside it. Semantic roles keep the hue the
* call site asked for.
* raw palette color: an unauthored neutral family derives it from the live
* WindowText and holds the glyph to a contrast floor, because a 2px glyph in
* body-text ink reads thinner than the text beside it. A theme that authors
* the neutral icon roles overrides that derivation. Semantic roles keep the
* hue the call site asked for.
*
* Emphasis is applied after the hue: Primary lifts a neutral glyph to the
* accent and pushes a tinted one to full contrast; Quiet blends either
* toward the surface down to the non-text legibility floor.
* Emphasis selects the family's authored variant, so the palette editor's
* rows are the whole source of truth. A caller that names a variant
* explicitly (a strong or soft role) pins that level at every emphasis.
*/
QColor resolve(QIcon::Mode mode) const;

View file

@ -46,16 +46,16 @@ DiscFill discFill(const QColor &tone)
namespace ThemeIconBadge
{
QColor glyphColor(const QColor &tone)
QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme)
{
// 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);
// The lifted 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, and the scheme is explicit because an authored
// quiet level is a dark tone on the dark scheme too.
return scheme == ThemeIconFill::Scheme::Dark ? tone.darker(GLYPH_DARKER) : QColor(Qt::white);
}
QSize glyphSize(const QSize &pixelSize)
@ -70,7 +70,7 @@ QSize glyphSize(const QSize &pixelSize)
return QSize(glyphSide, glyphSide);
}
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize)
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme)
{
if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) {
return {};
@ -100,12 +100,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(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth));
painter.setPen(QPen(tone.darker(RING_DARKER), ringWidth));
const qreal ringRadius = (side - ringWidth) / 2.0;
painter.drawEllipse(discRect.center(), ringRadius, ringRadius);
const QByteArray glyphSvg = resolveSvgStrokeWidth(
resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH);
resolveSvgColorSentinel(svg, glyphColor(tone, scheme).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH);
QSvgRenderer renderer(glyphSvg);
if (renderer.isValid()) {
const QSize size = glyphSize(pixelSize);

View file

@ -6,6 +6,8 @@
#ifndef THEME_ICON_BADGE_H
#define THEME_ICON_BADGE_H
#include "theme_icon_fill.h"
#include <QByteArray>
#include <QColor>
#include <QPixmap>
@ -34,13 +36,17 @@ namespace ThemeIconBadge
/**
* @brief Glyph color for a badge disc.
*
* White on the light scheme, the saturated tone darkened to an outline-dark ink
* White on the light scheme, the authored 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.
* the badge had before the filled treatments shared a fill. The scheme is passed
* in rather than inferred from the tone, because an authored quiet level is a
* dark color on the dark scheme as well.
*
* @param tone Disc color, already resolved for the icon mode.
* @param scheme Scheme the badge is painted for.
*/
QColor glyphColor(const QColor &tone);
QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme);
/** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */
QSize glyphSize(const QSize &pixelSize);
@ -63,9 +69,10 @@ QSize glyphSize(const QSize &pixelSize);
* @param tone Disc color, already resolved for the icon mode.
* @param pixelSize Canvas size in device pixels; a non-square canvas centers
* the badge and leaves the remaining area transparent.
* @param scheme Scheme the badge is painted for; selects the glyph ink.
* @return The composited badge, or a null pixmap for empty or invalid input.
*/
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize);
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme);
} // namespace ThemeIconBadge
#endif // THEME_ICON_BADGE_H

View file

@ -1,61 +1,22 @@
#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;
// The bottom end of every filled treatment. The authored tone is the top, so a
// single darkening keeps the emphasis level the palette author picked and still
// gives the shape the lit-from-above depth the pre-#7595 art had.
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);
return tone;
}
QColor bottomColor(const QColor &tone)
{
const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER;
return boost(tone, 0).darker(darker);
return tone.darker(GRADIENT_BOTTOM_DARKER);
}
} // namespace ThemeIconFill

View file

@ -12,50 +12,41 @@
* @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. The
* semantic tones are tuned for text, not for filling a whole shape; the
* dark-scheme tones in particular are desaturated and read washed out once a
* disc or a glyph carries them. The badge and the sticker share one fill rule so
* they sit next to each other with the same green or red.
* badge and the sticker share one fill rule so they sit next to each other with
* the same green or red.
*
* The rule needs no scheme switch: a tone whose value is already high is a
* dark-scheme tone, so its gradient is pulled down. Filling a shape with a tone
* that light is what reads pale.
* The authored tone is the top of that gradient: the palette author stores one
* color per emphasis level, so the treatment only adds depth by darkening the
* bottom. Hue, saturation and value all stay the author's, so the rendered
* ladder is the authored ladder on every scheme.
*/
namespace ThemeIconFill
{
/**
* @brief Returns the tone with a saturation floor, for a filled shape.
* @brief Which scheme a treatment is painted for.
*
* 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.
* The scheme is a property of the surface, not of the tone: an authored quiet
* level is deliberately darker than its secondary level on the dark scheme too,
* so it cannot be inferred from the tone's value. Callers know the surface
* palette and pass it explicitly.
*/
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);
enum class Scheme
{
Light,
Dark,
};
/**
* @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.
* @return The authored tone, unchanged.
*/
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.
* @return The authored tone, darkened for depth.
*/
QColor bottomColor(const QColor &tone);
} // namespace ThemeIconFill

View file

@ -12,20 +12,40 @@ namespace
// accent fill. Every shipped theme accents in blue or violet, so the borrowed hue
// stays neutral chrome instead of implying a state.
constexpr double ACCENT_MIX = 0.70;
// The Positive family owns green and the Negative family owns red. An accent in
// either band is a semantic color the theme did not mean as chrome, so the
// neutral glyph keeps the accent's intensity but not its hue.
constexpr int SEMANTIC_GREEN_START = 80;
constexpr int SEMANTIC_GREEN_END = 165;
constexpr int SEMANTIC_RED_START = 340;
constexpr int SEMANTIC_RED_END = 25;
// Slate blue: the chrome hue every shipped theme already leans toward, and the
// least likely to be mistaken for a state.
constexpr int NEUTRAL_CHROME_HUE = 210;
// An icon that labels a control is carrying text; see ui-assets.md.
constexpr double LABEL_CONTRAST_FLOOR = 4.5;
// A primary glyph has to out-read the default one on both schemes. The accent is
// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a
// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast
// floor shared with the default" keeps the primary above the secondary. A 7:1
// target does: it darkens the accent on light schemes and lightens it on dark
// ones, so the primary level always out-contrasts the default level.
constexpr double PRIMARY_CONTRAST = 7.0;
// A quiet glyph is blended this far into its surface. It has to stay findable
// without competing with the actions beside it.
constexpr double QUIET_BLEND = 0.55;
// WCAG 2.2 SC 1.4.11, the floor for a non-text element that carries meaning.
constexpr double QUIET_CONTRAST_FLOOR = 3.0;
// A primary glyph is the most chromatic member of its family. Contrast cannot
// order the ladder: on a dark surface the highest-contrast tone is the palest
// one, so a contrast target makes the primary look like a washed-out secondary
// and the ladder reads inverted. Primary instead raises the saturation by this
// many HSV points over the secondary and then solves the value back to the text
// floor, so it is louder in color at the same legibility.
constexpr int PRIMARY_SATURATION_STEP = 64;
// Quiet recedes by carrying less of the family's chroma, not by dropping closer
// to the surface: the disabled level owns the low-contrast end. It keeps this
// share of the secondary level's saturation.
constexpr int QUIET_SATURATION_PERCENT = 45;
// The quiet level shares the text floor with the other two levels.
constexpr double QUIET_CONTRAST_FLOOR = LABEL_CONTRAST_FLOOR;
// A disabled glyph recedes past the quiet level, so it is blended this much
// further into the surface...
constexpr double DISABLED_BLEND = 0.55;
// ...and then held to a floor below the quiet one so it stays findable.
constexpr double DISABLED_CONTRAST_FLOOR = 2.5;
// 8-bit quantization can drop a solved color just under the ratio it was solved
// for, so every search aims this much above the floor. Without it a palette
// value generated at exactly 4.5:1 measures 4.45:1 after the round trip.
constexpr double CONTRAST_ROUNDING_MARGIN = 0.10;
// Binary-search depth for reachContrast(). 24 halvings resolve to well under
// one 8-bit channel step, so the result is stable across platforms.
constexpr int CONTRAST_SEARCH_STEPS = 24;
@ -59,18 +79,18 @@ QColor blend(const QColor &color, const QColor &target, double amount)
QColor reachContrast(const QColor &color, const QColor &background, double minRatio)
{
if (contrastRatio(color, background) >= minRatio) {
const double target = minRatio + CONTRAST_ROUNDING_MARGIN;
if (contrastRatio(color, background) >= target) {
return color;
}
// Move away from the background: toward whichever pole actually reaches
// further. Black and white cross at a luminance of about 0.18, not 0.5, so
// the midpoint test would pick white on a surface where black is far more
// legible -- and could drag an ink that already cleared the floor to white.
// The hue is kept by interpolating, not by replacing.
const QColor black(Qt::black);
const QColor white(Qt::white);
const QColor pole = contrastRatio(black, background) >= contrastRatio(white, background) ? black : white;
if (contrastRatio(pole, background) < minRatio) {
// Move toward whichever pole the background leaves room for. A medium-light
// surface is lighter than mid-gray but still too light to reach a text floor
// with white, so "light background means move toward black" is the wrong
// test: compare what each pole can actually reach.
const double blackRatio = contrastRatio(QColor(Qt::black), background);
const double whiteRatio = contrastRatio(QColor(Qt::white), background);
const QColor pole = blackRatio >= whiteRatio ? QColor(Qt::black) : QColor(Qt::white);
if (qMax(blackRatio, whiteRatio) < target) {
return pole;
}
@ -90,9 +110,39 @@ QColor reachContrast(const QColor &color, const QColor &background, double minRa
return blend(color, pole, high);
}
bool isSemanticHue(int hue)
{
if (hue < 0) {
return false;
}
return (hue >= SEMANTIC_GREEN_START && hue <= SEMANTIC_GREEN_END) || hue >= SEMANTIC_RED_START ||
hue <= SEMANTIC_RED_END;
}
int neutralChromeHue()
{
return NEUTRAL_CHROME_HUE;
}
QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink)
{
const double inkLuminance = relativeLuminance(ink);
const auto distance = [inkLuminance](const QColor &surface) {
return std::abs(relativeLuminance(surface) - inkLuminance);
};
return distance(first) <= distance(second) ? first : second;
}
QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix)
{
return reachContrast(blend(text, accent, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb();
// A green or red accent is a Positive or Negative color, so borrowing its
// hue would paint neutral chrome the same color as a semantic state. Keep
// the accent's intensity, replace its hue with the chrome hue.
QColor source = accent;
if (isSemanticHue(accent.hue())) {
source = QColor::fromHsv(NEUTRAL_CHROME_HUE, accent.hsvSaturation(), qMax(accent.value(), 128));
}
return reachContrast(blend(text, source, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb();
}
double defaultAccentMix()
@ -105,27 +155,141 @@ double labelContrastFloor()
return LABEL_CONTRAST_FLOOR;
}
namespace
{
/** @brief Whether black reaches a higher contrast against @p surface than white. */
bool darkerPoleWins(const QColor &surface)
{
return ThemeIconInk::contrastRatio(QColor(Qt::black), surface) >=
ThemeIconInk::contrastRatio(QColor(Qt::white), surface);
}
/** @brief The luminance a color needs to reach @p ratio against @p surface. */
double targetLuminance(const QColor &surface, double ratio)
{
const double surfaceLuminance = ThemeIconInk::relativeLuminance(surface);
return darkerPoleWins(surface) ? (surfaceLuminance + 0.05) / ratio - 0.05
: (surfaceLuminance + 0.05) * ratio - 0.05;
}
/**
* @brief The value that puts @p hue at @p saturation on @p surface's floor.
*
* Luminance is monotone in HSV value at a fixed hue and saturation, so a
* bisection finds the boundary: the closest tone to the surface that still
* clears the floor, on either scheme. The direction depends on the surface:
* toward black (a light surface) the floor is a luminance ceiling, so the
* search keeps the highest passing value; toward white (a dark surface) it is a
* floor, so the search keeps the lowest passing value. Returns an invalid color
* when the tone cannot reach the floor at that saturation at all.
*/
QColor solveValue(int hue, int saturation, const QColor &surface, double floor)
{
const bool towardBlack = darkerPoleWins(surface);
const double target = targetLuminance(surface, floor + CONTRAST_ROUNDING_MARGIN);
// Toward black a tone is legible at or below the target luminance, toward
// white at or above it. Testing the wrong side is what let a saturated
// violet at full value look "too dark" on a light surface and fall back to
// the unchanged secondary.
const auto passes = [hue, saturation, target, towardBlack](int value) {
const double luminance = ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, value));
return towardBlack ? luminance <= target : luminance >= target;
};
if (passes(255)) {
// Toward black, full value is the palest tone: if even that clears the
// floor, the whole ramp does and the surface wants the authored tone.
// Toward white it is the palest tone but also the most legible one, so
// the search still starts from the failing end.
if (towardBlack) {
return QColor::fromHsv(hue, saturation, 255).toRgb();
}
} else if (!towardBlack) {
// Toward white the floor needs the palest tone; no darker value can
// reach it at this saturation.
return {};
}
int passing = towardBlack ? 0 : 255;
int failing = towardBlack ? 255 : 0;
for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) {
const int middle = (passing + failing) / 2;
if (passes(middle)) {
passing = middle;
} else {
failing = middle;
}
}
return QColor::fromHsv(hue, saturation, passing).toRgb();
}
/**
* @brief Raises a color's saturation, then solves its value for the floor.
*
* Saturation is raised before the value is solved, because reaching a contrast
* floor by adding white is what desaturates a tone in the first place. If the
* hue cannot reach the floor at the raised saturation inside the gamut, the step
* shrinks until it can, so an already-vivid tone keeps what it has. A gray has
* no saturation to raise and falls back to the contrast search.
*/
QColor chromaticStep(const QColor &color, const QColor &surface, double floor, int stepPoints)
{
if (color.hue() < 0) {
return ThemeIconInk::reachContrast(color, surface, floor);
}
const int hue = color.hue();
const int secondary = color.hsvSaturation();
for (int saturation = qMin(255, secondary + stepPoints); saturation > secondary; --saturation) {
const QColor solved = solveValue(hue, saturation, surface, floor);
if (solved.isValid()) {
return solved;
}
}
return ThemeIconInk::reachContrast(color, surface, floor);
}
/**
* @brief Mutes a color's saturation, then solves its value for the floor.
*
* The mirror of chromaticStep: quiet stays on the same floor as the other
* levels and recedes by carrying less of the family's chroma, so it cannot drop
* below the disabled level's contrast by construction. A gray has no saturation
* to mute and falls back to the contrast search.
*/
QColor mutedStep(const QColor &color, const QColor &surface, double floor, int percent)
{
if (color.hue() < 0) {
return ThemeIconInk::reachContrast(color, surface, floor);
}
const int saturation = qBound(0, qRound(color.hsvSaturation() * percent / 100.0), 255);
const QColor solved = solveValue(color.hue(), saturation, surface, floor);
return solved.isValid() ? solved : ThemeIconInk::reachContrast(color, surface, floor);
}
} // namespace
QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis)
{
switch (emphasis) {
case IconEmphasis::Primary:
return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb();
return chromaticStep(color, surface, LABEL_CONTRAST_FLOOR, PRIMARY_SATURATION_STEP).toRgb();
case IconEmphasis::Quiet:
return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb();
return mutedStep(color, surface, QUIET_CONTRAST_FLOOR, QUIET_SATURATION_PERCENT).toRgb();
case IconEmphasis::Secondary:
break;
}
return color;
}
double primaryContrastFloor()
QColor disabledInk(const QColor &quiet, const QColor &surface)
{
return PRIMARY_CONTRAST;
return reachContrast(blend(quiet, surface, DISABLED_BLEND), surface, DISABLED_CONTRAST_FLOOR).toRgb();
}
double quietBlend()
double primaryContrastFloor()
{
return QUIET_BLEND;
return LABEL_CONTRAST_FLOOR;
}
double quietContrastFloor()

View file

@ -24,7 +24,9 @@
* on. The chroma is what makes it read as an icon rather than as leftover text;
* the floor keeps a theme with a weak text color from producing a glyph that is
* not legible. Neither step invents a color: both are derived from the palette
* the theme already ships.
* the theme already ships. An unauthored neutral family stops at the floor: the
* live platform palette is the only source that knows the actual scheme, so a
* chroma pinned in a palette file could belong to the other one.
*
* The rule only applies to the neutral label roles. A semantic tint
* (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the
@ -32,6 +34,27 @@
*/
namespace ThemeIconInk
{
/**
* @brief Whether @p hue belongs to the band the Positive or Negative family owns.
*
* A green accent is a Positive color and a red accent is a Negative one, whether
* or not the theme means it that way. Neutral chrome must not borrow either hue,
* or "settings" and "add" are painted the same color.
*/
bool isSemanticHue(int hue);
/** @brief The hue neutral chrome falls back to when the accent is semantic. */
int neutralChromeHue();
/**
* @brief The harder of two surfaces to keep an ink legible on.
*
* Contrast is monotone in the luminance distance between an ink and its
* surface, so the surface nearest @p ink is the one it reads worst on. Choosing
* that one keeps a single authored ink above the floor on both.
*/
QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink);
/**
* @brief WCAG relative luminance of an opaque color.
*/
@ -54,9 +77,10 @@ QColor blend(const QColor &color, const QColor &target, double amount);
/**
* @brief Pushes @p color away from @p background until it reaches @p minRatio.
*
* Moves @p color toward black on a light background and toward white on a dark
* one, so the hue survives the adjustment. Returns @p color unchanged when it
* already meets the floor, which is the common case for a shipped palette.
* Moves @p color toward whichever pole, black or white, actually reaches
* @p minRatio against @p background, so the hue survives the adjustment.
* Returns @p color unchanged when it already meets the floor, which is the
* common case for a shipped palette.
*
* @param color Color to adjust.
* @param background Surface the color is drawn on.
@ -82,7 +106,9 @@ double defaultAccentMix();
* @param accent The theme accent, normally `QPalette::Highlight`.
* @param accentMix How much of the accent color to borrow. Defaults to
* defaultAccentMix(); previews and tests sweep it to compare
* strengths, production callers do not pass it.
* strengths. Production passes 0 for an unauthored neutral
* family, where the live platform palette is the only source
* that knows the actual scheme.
* @return The ink to resolve `currentColor` to, as an RGB color.
*/
QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix = defaultAccentMix());
@ -99,13 +125,12 @@ double labelContrastFloor();
/**
* @brief Applies an emphasis level to an already-hued glyph color.
*
* Secondary keeps the hue untouched, so it is the no-op the default needs.
* Primary pushes the color to a higher floor than the default, because the
* accent is a mid-tone: on a light scheme it is lighter than the neutral ink and
* on a dark one darker, so only a stronger contrast target keeps the primary
* level above the secondary on both. Quiet
* blends the color toward the surface it sits on and stops at the non-text
* legibility floor, so a labelling glyph recedes without disappearing.
* The three levels share one legibility floor and differ by chroma, because
* contrast cannot order them: on a dark surface the highest-contrast tone is the
* palest one, so a contrast target inverts the ladder. Primary is the most
* chromatic member of its family, secondary keeps the color untouched (the
* no-op the default needs), and quiet mutes the color to a fraction of its
* saturation before solving it back to the same floor.
*
* @param color Hue-resolved glyph color.
* @param surface Surface the glyph is drawn on.
@ -114,13 +139,27 @@ double labelContrastFloor();
*/
QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis);
/** @brief Contrast floor the primary level reaches, so it out-reads the default. */
/**
* @brief The ink a disabled glyph is painted with, given the quiet level.
*
* A disabled glyph is the quiet ink receded further toward the surface and held
* to a floor below the quiet one. The palette's own disabled text is a fixed
* gray whose contrast varies with the surface, so on the darkest themes it
* out-reads the quiet level and an enabled glyph looks more disabled than a
* disabled one. Deriving the disabled ink from the quiet ink makes that order
* structural instead of incidental.
*/
QColor disabledInk(const QColor &quiet, const QColor &surface);
/**
* @brief Contrast floor the primary level reaches, so it stays legible.
*
* Primary is ordered by chroma, not contrast, so it shares the text floor
* rather than clearing a higher one.
*/
double primaryContrastFloor();
/** @brief How far a quiet glyph is blended into its surface. */
double quietBlend();
/** @brief Contrast floor a quiet glyph keeps, the WCAG non-text minimum. */
/** @brief Contrast floor the quiet level reaches, shared with the other levels. */
double quietContrastFloor();
} // namespace ThemeIconInk

View file

@ -30,7 +30,7 @@ namespace ThemeIconSticker
{
QColor outlineColor(const QColor &tone)
{
return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER);
return tone.darker(OUTLINE_DARKER);
}
QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize)

View file

@ -3,6 +3,7 @@
#include "../../client/settings/cache_settings.h"
#include "pixel_map_generator.h"
#include "theme_config.h"
#include "theme_icon_ink.h"
#include <QApplication>
#include <QChar>
@ -651,8 +652,37 @@ QColor ThemeManager::appColor(AppColor::Role role) const
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255),
qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255));
}
case AppColor::Icon:
case AppColor::IconStrong:
case AppColor::IconSoft: {
// Legacy fallback for a theme written before the icon roles
// existed, or one that deliberately leaves the neutral family
// unauthored (System). No accent chroma is borrowed here: the
// palette file that would carry it cannot know the platform scheme,
// so neutral ink is the live WindowText held to the label floor.
// The three levels are the ink rule this series shipped, applied
// once here so authored roles never get recomputed at paint time.
const QPalette &palette = qApp->palette();
const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Button), text);
const QColor ink = ThemeIconInk::glyphInk(text, surface, accent, 0.0);
switch (role) {
case AppColor::IconStrong:
return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary);
case AppColor::IconSoft:
return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Quiet);
case AppColor::Icon:
default:
return ink;
}
}
case AppColor::Positive:
case AppColor::PositiveStrong:
case AppColor::PositiveSoft:
case AppColor::Negative:
case AppColor::NegativeStrong:
case AppColor::NegativeSoft:
// Semantic colors are independent of the accent: a missing role must
// not silently paint "remove" in the theme's highlight color.
return defaultAppColor(role, isDarkModeActive());

View file

@ -71,6 +71,12 @@ Accent = #4a82e6
[AppColors]
AccentStrong = #3a6fd6
AccentSoft = #8fb8f2
Positive = #6dd58c
Negative = #ff8a80
Icon = #7ca0e5
IconStrong = #6a9dff
IconSoft = #93a1ba
Positive = #5bb174
PositiveStrong = #30b758
PositiveSoft = #84a98f
Negative = #ed8077
NegativeStrong = #ff7367
NegativeSoft = #be9794

View file

@ -71,6 +71,12 @@ Accent = #2f6fd6
[AppColors]
AccentStrong = #3a6fd6
AccentSoft = #8fb8f2
Positive = #1b7f3b
Negative = #b3261e
Icon = #2f58ac
IconStrong = #054ddf
IconSoft = #505c76
Positive = #166931
PositiveStrong = #006b22
PositiveSoft = #40634b
Negative = #b0251d
NegativeStrong = #ba0a00
NegativeSoft = #7c504d

View file

@ -69,7 +69,14 @@ Accent = #ff1e1e1e
[AppColors]
AccentStrong = #ff139740
AccentSoft = #ffc9fd62
Positive = #6dd58c
Negative = #ff8a80
AccentStrong = #ff139740
AccentSoft = #ffc9fd62
Icon = #74ace4
IconStrong = #58acff
IconSoft = #95aabf
Positive = #5fb97a
PositiveStrong = #32bf5c
PositiveSoft = #8ab196
Negative = #f8867c
NegativeStrong = #ff8075
NegativeSoft = #c79e9b

View file

@ -69,7 +69,14 @@ Accent = #fff0f0f0
[AppColors]
AccentStrong = #ff139740
AccentSoft = #ffc9fd62
Positive = #1b7f3b
Negative = #b3261e
AccentStrong = #ff139740
AccentSoft = #ffc9fd62
Icon = #196dc2
IconStrong = #006ad4
IconSoft = #546f8a
Positive = #1b7d3a
PositiveStrong = #007e28
PositiveSoft = #4c765a
Negative = #cf2c23
NegativeStrong = #da0c00
NegativeSoft = #935f5c

View file

@ -71,6 +71,12 @@ Accent = #c9995a
[AppColors]
AccentStrong = #b7823b
AccentSoft = #e4c58f
Positive = #6dd58c
Negative = #ff8a80
Icon = #bd8d4a
IconStrong = #d0851e
IconSoft = #a79479
Positive = #55a66d
PositiveStrong = #2dab52
PositiveSoft = #7c9f86
Negative = #df7970
NegativeStrong = #ff5e50
NegativeSoft = #b38e8b

View file

@ -71,6 +71,12 @@ Accent = #a5712f
[AppColors]
AccentStrong = #b7823b
AccentSoft = #e4c58f
Positive = #1b7f3b
Negative = #b3261e
Icon = #725025
IconStrong = #7e4b09
IconSoft = #625544
Positive = #15642e
PositiveStrong = #006621
PositiveSoft = #3d5f48
Negative = #a7231c
NegativeStrong = #b10a00
NegativeSoft = #764c4a

View file

@ -71,6 +71,12 @@ Accent = #6a4df0
[AppColors]
AccentStrong = #5b3ee0
AccentSoft = #a28bf7
Positive = #6dd58c
Negative = #ff8a80
Icon = #9380eb
IconStrong = #927aff
IconSoft = #928cb0
Positive = #519f69
PositiveStrong = #2ba44f
PositiveSoft = #779881
Negative = #d5736b
NegativeStrong = #ff4f40
NegativeSoft = #ab8885

View file

@ -71,6 +71,12 @@ Accent = #5a3ee2
[AppColors]
AccentStrong = #5b3ee0
AccentSoft = #a28bf7
Positive = #1b7f3b
Negative = #b3261e
Icon = #5338ce
IconStrong = #4118fe
IconSoft = #585078
Positive = #15622e
PositiveStrong = #006420
PositiveSoft = #3c5d47
Negative = #a4231b
NegativeStrong = #ae0900
NegativeSoft = #744b48

View file

@ -3,8 +3,17 @@
# overrides. Only the identity and semantic app colors are provided here, so
# buttons keep their classic greens while the actual palette stays native to
# the OS.
# The neutral icon family is deliberately not authored: the palette file is
# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer
# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a
# platform palette of the other scheme. Neutral ink derives from the live
# WindowText instead.
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff6dd58c
Negative = #ffff8a80
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff6dd58c
PositiveStrong = #ff32c05c
PositiveSoft = #ff8ab196
Negative = #ffff8a80
NegativeStrong = #ffff8378
NegativeSoft = #ffc79e9b

View file

@ -3,8 +3,17 @@
# overrides. Only the identity and semantic app colors are provided here, so
# buttons keep their classic greens while the actual palette stays native to
# the OS.
# The neutral icon family is deliberately not authored: the palette file is
# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer
# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a
# platform palette of the other scheme. Neutral ink derives from the live
# WindowText instead.
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff1b7f3b
Negative = #ffb3261e
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff1a7d3a
PositiveStrong = #ff007e28
PositiveSoft = #ff4c7659
Negative = #ffb3261e
NegativeStrong = #ffda0b00
NegativeSoft = #ff935f5c

View file

@ -71,6 +71,12 @@ Accent = #8fa6c0
[AppColors]
AccentStrong = #54687e
AccentSoft = #9db0c4
Positive = #6dd58c
Negative = #ff8a80
Icon = #8493a2
IconStrong = #6b95bf
IconSoft = #8c9399
Positive = #52a16a
PositiveStrong = #2ba650
PositiveSoft = #789a82
Negative = #d8756c
NegativeStrong = #ff5344
NegativeSoft = #ad8a86

View file

@ -71,6 +71,12 @@ Accent = #54687e
[AppColors]
AccentStrong = #54687e
AccentSoft = #9db0c4
Positive = #1b7f3b
Negative = #b3261e
Icon = #495a6e
IconStrong = #375a84
IconSoft = #525961
Positive = #16662f
PositiveStrong = #006721
PositiveSoft = #3e6049
Negative = #aa241c
NegativeStrong = #b40a00
NegativeSoft = #784d4b

View file

@ -187,9 +187,10 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme)
qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER;
for (const Family &family : families) {
const QColor raw = config.appColors.value(family.role, defaultAppColor(family.role, dark));
ASSERT_TRUE(raw.isValid()) << theme.toStdString() << " " << family.name;
const QColor tone = ThemeIconFill::saturated(raw);
// The authored role is the tone the filled shape carries; the
// treatments apply their own fill rule from it.
const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark));
ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name;
for (int state = 0; state < 2; ++state) {
const bool isDisabled = state == 1;
@ -221,7 +222,9 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme)
if (treatment == 1) {
return ThemeIconSticker::renderSticker(svg, ink, pixelSize);
}
return ThemeIconBadge::renderBadge(svg, ink, pixelSize);
return ThemeIconBadge::renderBadge(svg, ink, pixelSize,
dark ? ThemeIconFill::Scheme::Dark
: ThemeIconFill::Scheme::Light);
});
const qreal x =
cellX + 6 + static_cast<qreal>(glyphIndex) * SLOT + (SLOT - side) / 2.0;

File diff suppressed because it is too large Load diff

View file

@ -155,13 +155,28 @@ TEST_F(ThemeMigrationTest, IsIdempotent)
TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme)
{
EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b")));
EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1a7d3a")));
EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#007e28")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#32c05c")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#4c7659")));
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#8ab196")));
EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e")));
EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80")));
// Accent roles are derived from the palette, so they have no static default.
EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, false).isValid());
EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, true).isValid());
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#da0b00")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c")));
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b")));
// Accent and neutral icon roles have no static default: an older theme
// written before the roles existed falls back to deriving them from its
// palette rather than pinning one scheme's ink.
for (const bool dark : {false, true}) {
EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, dark).isValid());
EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, dark).isValid());
EXPECT_FALSE(defaultAppColor(AppColor::Icon, dark).isValid());
EXPECT_FALSE(defaultAppColor(AppColor::IconStrong, dark).isValid());
EXPECT_FALSE(defaultAppColor(AppColor::IconSoft, dark).isValid());
}
}
TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles)
@ -170,10 +185,20 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles)
ASSERT_TRUE(dir.isValid());
PaletteConfig config;
const QColor positive(QStringLiteral("#1b7f3b"));
const QColor negative(QStringLiteral("#b3261e"));
config.appColors[AppColor::Positive] = positive;
config.appColors[AppColor::Negative] = negative;
const QMap<AppColor::Role, QColor> roles = {
{AppColor::Icon, QColor(QStringLiteral("#2f59ad"))},
{AppColor::IconStrong, QColor(QStringLiteral("#213e79"))},
{AppColor::IconSoft, QColor(QStringLiteral("#687899"))},
{AppColor::Positive, QColor(QStringLiteral("#1b7f3b"))},
{AppColor::PositiveStrong, QColor(QStringLiteral("#104a22"))},
{AppColor::PositiveSoft, QColor(QStringLiteral("#5d7f6f"))},
{AppColor::Negative, QColor(QStringLiteral("#b3261e"))},
{AppColor::NegativeStrong, QColor(QStringLiteral("#7c1b15"))},
{AppColor::NegativeSoft, QColor(QStringLiteral("#9a6c6f"))},
};
for (auto it = roles.cbegin(); it != roles.cend(); ++it) {
config.appColors[it.key()] = it.value();
}
const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml"));
QFile file(path);
@ -182,10 +207,10 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles)
file.close();
const PaletteConfig parsed = PaletteConfig::fromFile(path);
ASSERT_TRUE(parsed.appColors.contains(AppColor::Positive));
ASSERT_TRUE(parsed.appColors.contains(AppColor::Negative));
EXPECT_EQ(parsed.appColors.value(AppColor::Positive), positive);
EXPECT_EQ(parsed.appColors.value(AppColor::Negative), negative);
for (auto it = roles.cbegin(); it != roles.cend(); ++it) {
ASSERT_TRUE(parsed.appColors.contains(it.key()));
EXPECT_EQ(parsed.appColors.value(it.key()), it.value());
}
}
// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and