Address review findings

This commit is contained in:
Lukas Brübach 2026-11-08 14:50:35 +01:00
parent 181bd9d02c
commit d2af5c95f4
20 changed files with 154 additions and 97 deletions

View file

@ -458,17 +458,18 @@ QColor defaultAppColor(AppColor::Role role, bool dark)
switch (role) { switch (role) {
case AppColor::AccentStrong: case AppColor::AccentStrong:
case AppColor::AccentSoft: case AppColor::AccentSoft:
// The neutral icon family is palette-derived like the accent, so a // The neutral icon family is palette-derived like the accent: a theme
// theme without an authored icon family keeps following the platform // that leaves it unauthored (System, and every theme written before the
// instead of pinning one scheme's ink. // roles existed) keeps the live platform palette's WindowText instead
// of pinning one scheme's ink.
case AppColor::Icon: case AppColor::Icon:
case AppColor::IconStrong: case AppColor::IconStrong:
case AppColor::IconSoft: case AppColor::IconSoft:
return {}; return {};
// The semantic family follows the same chroma ladder as the authored // The semantic family follows the same chroma ladder as the authored
// themes. A theme without stored values (System) has no per-surface // themes. A theme that authors none of it has no per-surface palette to
// palette to measure against, so these are authored against the // measure against, so these are authored against the representative
// representative platform surfaces the System theme runs on. // platform surfaces the System theme runs on.
case AppColor::Positive: case AppColor::Positive:
return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1a7d3a")); return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1a7d3a"));
case AppColor::PositiveStrong: case AppColor::PositiveStrong:

View file

@ -63,11 +63,13 @@ Q_ENUM_NS(Role)
* *
* Accent roles and the neutral Icon family return an invalid QColor because * Accent roles and the neutral Icon family return an invalid QColor because
* ThemeManager derives them from the palette: the accent from the highlight, * ThemeManager derives them from the palette: the accent from the highlight,
* the icon ink from the neutral text color plus the highlight. Semantic roles * 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 * return an opaque default that suits the scheme, so themes written before the
* role existed still render it. A theme with no authored icon family keeps * role existed still render it. The System theme deliberately leaves the
* deriving its icon ink from the platform palette instead of pinning one * neutral family unauthored: the palette file it picks cannot know the platform
* scheme's colors. * 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); QColor defaultAppColor(AppColor::Role role, bool dark);

View file

@ -64,10 +64,19 @@ AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis)
* *
* The fill needs the surface scheme to choose the badge glyph ink, and it cannot * 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 * read it from the tone: an authored quiet level is a dark color on the dark
* scheme too. * 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() 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(); const QPalette &palette = qApp->palette();
return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark
: ThemeIconFill::Scheme::Light; : ThemeIconFill::Scheme::Light;

View file

@ -104,11 +104,11 @@ public:
* hue and saturation stay the author's. * hue and saturation stay the author's.
* *
* Neutral label roles (WindowText, Text) resolve to icon ink rather than the * Neutral label roles (WindowText, Text) resolve to icon ink rather than the
* raw palette color: ThemeIconInk::glyphInk derives it from the accent and * raw palette color: an unauthored neutral family derives it from the live
* holds the glyph to a contrast floor, because a 2px glyph in body-text ink * WindowText and holds the glyph to a contrast floor, because a 2px glyph in
* reads thinner than the text beside it. A theme that authors the neutral * body-text ink reads thinner than the text beside it. A theme that authors
* icon roles overrides that derivation. Semantic roles keep the hue the call * the neutral icon roles overrides that derivation. Semantic roles keep the
* site asked for. * hue the call site asked for.
* *
* Emphasis selects the family's authored variant, so the palette editor's * 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 * rows are the whole source of truth. A caller that names a variant

View file

@ -177,26 +177,50 @@ double targetLuminance(const QColor &surface, double ratio)
* *
* Luminance is monotone in HSV value at a fixed hue and saturation, so a * 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 * bisection finds the boundary: the closest tone to the surface that still
* clears the floor, on either scheme. Returns an invalid color when even full * clears the floor, on either scheme. The direction depends on the surface:
* value cannot reach the floor at that saturation. * 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) 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); const double target = targetLuminance(surface, floor + CONTRAST_ROUNDING_MARGIN);
if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, 255)) < target) { // 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 {}; return {};
} }
int low = 0;
int high = 255; int passing = towardBlack ? 0 : 255;
int failing = towardBlack ? 255 : 0;
for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) {
const int middle = (low + high) / 2; const int middle = (passing + failing) / 2;
if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, middle)) >= target) { if (passes(middle)) {
high = middle; passing = middle;
} else { } else {
low = middle; failing = middle;
} }
} }
return QColor::fromHsv(hue, saturation, high).toRgb(); return QColor::fromHsv(hue, saturation, passing).toRgb();
} }
/** /**

View file

@ -24,7 +24,9 @@
* on. The chroma is what makes it read as an icon rather than as leftover text; * 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 * 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 * 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 * The rule only applies to the neutral label roles. A semantic tint
* (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the * (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the
@ -104,7 +106,9 @@ double defaultAccentMix();
* @param accent The theme accent, normally `QPalette::Highlight`. * @param accent The theme accent, normally `QPalette::Highlight`.
* @param accentMix How much of the accent color to borrow. Defaults to * @param accentMix How much of the accent color to borrow. Defaults to
* defaultAccentMix(); previews and tests sweep it to compare * 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. * @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()); QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix = defaultAccentMix());

View file

@ -656,14 +656,17 @@ QColor ThemeManager::appColor(AppColor::Role role) const
case AppColor::IconStrong: case AppColor::IconStrong:
case AppColor::IconSoft: { case AppColor::IconSoft: {
// Legacy fallback for a theme written before the icon roles // Legacy fallback for a theme written before the icon roles
// existed, or one with no authored icon family. The three levels // existed, or one that deliberately leaves the neutral family
// are the ink rule this series shipped, applied once here so // unauthored (System). No accent chroma is borrowed here: the
// authored roles never get recomputed at paint time. // 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 QPalette &palette = qApp->palette();
const QColor text = palette.color(QPalette::Active, QPalette::WindowText); const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Button), text); palette.color(QPalette::Active, QPalette::Button), text);
const QColor ink = ThemeIconInk::glyphInk(text, surface, accent); const QColor ink = ThemeIconInk::glyphInk(text, surface, accent, 0.0);
switch (role) { switch (role) {
case AppColor::IconStrong: case AppColor::IconStrong:
return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary); return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary);

View file

@ -79,4 +79,4 @@ PositiveStrong = #30b758
PositiveSoft = #84a98f PositiveSoft = #84a98f
Negative = #ed8077 Negative = #ed8077
NegativeStrong = #ff7367 NegativeStrong = #ff7367
NegativeSoft = #be9794 NegativeSoft = #be9794

View file

@ -79,4 +79,4 @@ PositiveStrong = #006b22
PositiveSoft = #40634b PositiveSoft = #40634b
Negative = #b0251d Negative = #b0251d
NegativeStrong = #ba0a00 NegativeStrong = #ba0a00
NegativeSoft = #7c504d NegativeSoft = #7c504d

View file

@ -79,4 +79,4 @@ PositiveStrong = #32bf5c
PositiveSoft = #8ab196 PositiveSoft = #8ab196
Negative = #f8867c Negative = #f8867c
NegativeStrong = #ff8075 NegativeStrong = #ff8075
NegativeSoft = #c79e9b NegativeSoft = #c79e9b

View file

@ -79,4 +79,4 @@ PositiveStrong = #007e28
PositiveSoft = #4c765a PositiveSoft = #4c765a
Negative = #cf2c23 Negative = #cf2c23
NegativeStrong = #da0c00 NegativeStrong = #da0c00
NegativeSoft = #935f5c NegativeSoft = #935f5c

View file

@ -79,4 +79,4 @@ PositiveStrong = #2dab52
PositiveSoft = #7c9f86 PositiveSoft = #7c9f86
Negative = #df7970 Negative = #df7970
NegativeStrong = #ff5e50 NegativeStrong = #ff5e50
NegativeSoft = #b38e8b NegativeSoft = #b38e8b

View file

@ -79,4 +79,4 @@ PositiveStrong = #006621
PositiveSoft = #3d5f48 PositiveSoft = #3d5f48
Negative = #a7231c Negative = #a7231c
NegativeStrong = #b10a00 NegativeStrong = #b10a00
NegativeSoft = #764c4a NegativeSoft = #764c4a

View file

@ -79,4 +79,4 @@ PositiveStrong = #2ba44f
PositiveSoft = #779881 PositiveSoft = #779881
Negative = #d5736b Negative = #d5736b
NegativeStrong = #ff4f40 NegativeStrong = #ff4f40
NegativeSoft = #ab8885 NegativeSoft = #ab8885

View file

@ -79,4 +79,4 @@ PositiveStrong = #006420
PositiveSoft = #3c5d47 PositiveSoft = #3c5d47
Negative = #a4231b Negative = #a4231b
NegativeStrong = #ae0900 NegativeStrong = #ae0900
NegativeSoft = #744b48 NegativeSoft = #744b48

View file

@ -1,21 +1,19 @@
# The System theme derives all of its colors from the platform palette # The System theme derives all of its colors from the platform palette
# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette
# overrides. Only the identity, neutral-ink and semantic app colors are # overrides. Only the identity and semantic app colors are provided here, so
# provided here, so buttons keep their classic greens while the actual palette # buttons keep their classic greens while the actual palette stays native to
# stays native to the OS. # the OS.
# The neutral icon family is authored instead of derived: deriving it from the # The neutral icon family is deliberately not authored: the palette file is
# platform palette picked up the scheme's accent chroma, which painted neutral # picked from the theme's scheme, which on Qt < 6.5 is always light and on newer
# chrome blue. Neutral ink stays neutral here, black on the light scheme and # Qt can be unknown, so a fixed black-or-white ladder would be invisible on a
# white on the dark one, regardless of what the platform's accent carries. # platform palette of the other scheme. Neutral ink derives from the live
# WindowText instead.
[AppColors] [AppColors]
AccentStrong = #ff148c3c AccentStrong = #ff148c3c
AccentSoft = #ff78c850 AccentSoft = #ff78c850
Icon = #ffe0e0e0
IconStrong = #ffffffff
IconSoft = #ffb0b0b0
Positive = #ff6dd58c Positive = #ff6dd58c
PositiveStrong = #ff32c05c PositiveStrong = #ff32c05c
PositiveSoft = #ff8ab196 PositiveSoft = #ff8ab196
Negative = #ffff8a80 Negative = #ffff8a80
NegativeStrong = #ffff8378 NegativeStrong = #ffff8378
NegativeSoft = #ffc79e9b NegativeSoft = #ffc79e9b

View file

@ -1,21 +1,19 @@
# The System theme derives all of its colors from the platform palette # The System theme derives all of its colors from the platform palette
# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette
# overrides. Only the identity, neutral-ink and semantic app colors are # overrides. Only the identity and semantic app colors are provided here, so
# provided here, so buttons keep their classic greens while the actual palette # buttons keep their classic greens while the actual palette stays native to
# stays native to the OS. # the OS.
# The neutral icon family is authored instead of derived: deriving it from the # The neutral icon family is deliberately not authored: the palette file is
# platform palette picked up the scheme's accent chroma, which painted neutral # picked from the theme's scheme, which on Qt < 6.5 is always light and on newer
# chrome blue. Neutral ink stays neutral here, black on the light scheme and # Qt can be unknown, so a fixed black-or-white ladder would be invisible on a
# white on the dark one, regardless of what the platform's accent carries. # platform palette of the other scheme. Neutral ink derives from the live
# WindowText instead.
[AppColors] [AppColors]
AccentStrong = #ff148c3c AccentStrong = #ff148c3c
AccentSoft = #ff78c850 AccentSoft = #ff78c850
Icon = #ff1a1a1a
IconStrong = #ff000000
IconSoft = #ff5f5f5f
Positive = #ff1a7d3a Positive = #ff1a7d3a
PositiveStrong = #ff007e28 PositiveStrong = #ff007e28
PositiveSoft = #ff4c7659 PositiveSoft = #ff4c7659
Negative = #ffb3261e Negative = #ffb3261e
NegativeStrong = #ffda0b00 NegativeStrong = #ffda0b00
NegativeSoft = #ff935f5c NegativeSoft = #ff935f5c

View file

@ -79,4 +79,4 @@ PositiveStrong = #2ba650
PositiveSoft = #789a82 PositiveSoft = #789a82
Negative = #d8756c Negative = #d8756c
NegativeStrong = #ff5344 NegativeStrong = #ff5344
NegativeSoft = #ad8a86 NegativeSoft = #ad8a86

View file

@ -79,4 +79,4 @@ PositiveStrong = #006721
PositiveSoft = #3e6049 PositiveSoft = #3e6049
Negative = #aa241c Negative = #aa241c
NegativeStrong = #b40a00 NegativeStrong = #b40a00
NegativeSoft = #784d4b NegativeSoft = #784d4b

View file

@ -80,16 +80,18 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected)
/** /**
* @brief The ink the engine resolves a neutral glyph to, for @p palette. * @brief The ink the engine resolves a neutral glyph to, for @p palette.
* *
* Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink holds
* borrows accent chroma and holds the result to a contrast floor. Tests that * the platform text color to a contrast floor. Tests that pin the rendered color
* pin the rendered color ask the rule instead of restating it. * ask the rule instead of restating it. No accent chroma is borrowed: this is
* the fallback an unauthored neutral family takes, so it works whatever scheme
* the platform palette actually is.
*/ */
QColor expectedNeutralInk(const QPalette &palette) QColor expectedNeutralInk(const QPalette &palette)
{ {
const QColor text = palette.color(QPalette::Active, QPalette::WindowText); const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Button), text); palette.color(QPalette::Active, QPalette::Button), text);
return ThemeIconInk::glyphInk(text, surface, palette.color(QPalette::Active, QPalette::Highlight)); return ThemeIconInk::glyphInk(text, surface, palette.color(QPalette::Active, QPalette::Highlight), 0.0);
} }
/** /**
@ -327,10 +329,16 @@ TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText)
qApp->setPalette(palette); qApp->setPalette(palette);
const IconTint tint(QPalette::WindowText); const IconTint tint(QPalette::WindowText);
// The normal ink is derived from the active text color, but it is icon ink // The normal ink is the accent-free neutral fallback: the platform text
// rather than the text color itself. // color held to the label floor. A text color that already clears the floor
EXPECT_NE(tint.resolve(QIcon::Normal), activeText); // is the ink verbatim, so this pins the rule and the floor, not a shift.
EXPECT_EQ(tint.resolve(QIcon::Normal), expectedNeutralInk(qApp->palette())); const QColor normal = tint.resolve(QIcon::Normal);
EXPECT_EQ(normal, expectedNeutralInk(qApp->palette()));
const QPalette resolved = qApp->palette();
const QColor surface = ThemeIconInk::harderSurface(resolved.color(QPalette::Active, QPalette::Window),
resolved.color(QPalette::Active, QPalette::Button),
resolved.color(QPalette::Active, QPalette::WindowText));
EXPECT_GE(ThemeIconInk::contrastRatio(normal, surface), ThemeIconInk::labelContrastFloor());
// A selected row paints on the highlight, so the ink is the palette's // A selected row paints on the highlight, so the ink is the palette's
// highlighted text rather than the icon ink. // highlighted text rather than the icon ink.
EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText); EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText);
@ -1194,9 +1202,9 @@ InkPalette shippedPalette(const QString &theme, bool dark)
// A theme may author only [AppColors] (System does), so appColors alone do // A theme may author only [AppColors] (System does), so appColors alone do
// not mean it ships a palette: the keys have to be present. The authored // not mean it ships a palette: the keys have to be present. The authored
// neutral-family assertions therefore cover only themes with a palette; // neutral-family assertions therefore cover only themes with a palette;
// System's authored neutral family is checked for authorship and ordering // System deliberately leaves its neutral family unauthored, which
// by ShippedPalettesAuthorTheEmphasisLadder, and its semantic fallbacks // ShippedPalettesAuthorTheEmphasisLadder pins, and its semantic fallbacks
// against representative platform surfaces by // are measured against representative platform surfaces by
// DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces. // DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces.
if (config.colors.isEmpty()) { if (config.colors.isEmpty()) {
return {}; return {};
@ -1211,9 +1219,9 @@ InkPalette shippedPalette(const QString &theme, bool dark)
/** /**
* @brief The [AppColors] a shipped theme authors, even when it ships no palette. * @brief The [AppColors] a shipped theme authors, even when it ships no palette.
* *
* System deliberately has no palette of its own, but it still authors every * System deliberately has no palette of its own, but it still authors its
* app-color role, including the neutral icon family that used to be derived * semantic roles. The neutral icon family stays unauthored there so it follows
* from the platform palette. * the live platform palette; see ShippedPalettesAuthorTheEmphasisLadder.
*/ */
QMap<AppColor::Role, QColor> shippedAppColors(const QString &theme, bool dark) QMap<AppColor::Role, QColor> shippedAppColors(const QString &theme, bool dark)
{ {
@ -1257,6 +1265,29 @@ TEST(IconInk, ContrastFloorPicksThePoleItCanReach)
EXPECT_GE(ThemeIconInk::contrastRatio(solved, background), 4.5); EXPECT_GE(ThemeIconInk::contrastRatio(solved, background), 4.5);
} }
/**
* @brief Primary stays louder than secondary on a light surface.
*
* A saturated blue or violet at full value is already dark enough to clear the
* label floor on a light surface. When solveValue() read the target as a
* minimum in both directions it rejected that tone, walked the whole saturation
* ladder and fell back to the unchanged secondary, so primary was never
* distinguishable. #5a3ee2 on #efeff4 is the shipped Plasma accent's shape.
*/
TEST(IconInk, PrimaryRaisesAChromaticToneOnALightSurface)
{
const QColor surface(QStringLiteral("#efeff4"));
const QColor violet(QStringLiteral("#5a3ee2"));
ASSERT_GE(ThemeIconInk::contrastRatio(violet, surface), ThemeIconInk::labelContrastFloor())
<< "the fixture must already be legible, so the fallback would be a no-op";
const QColor primary = ThemeIconInk::forEmphasis(violet, surface, IconEmphasis::Primary);
ASSERT_TRUE(primary.isValid());
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor());
EXPECT_GT(primary.hsvSaturation(), violet.hsvSaturation()) << "primary fell back to the unchanged secondary";
EXPECT_NEAR(primary.hue(), violet.hue(), 2);
}
/** /**
* @brief A green or red accent must not tint neutral chrome. * @brief A green or red accent must not tint neutral chrome.
* *
@ -1675,30 +1706,17 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder)
EXPECT_GT(checked, 0) << "no shipped palette was loadable"; EXPECT_GT(checked, 0) << "no shipped palette was loadable";
// The System theme ships no palette of its own, so its families have no // The System theme ships no palette of its own, so its families have no
// authored surfaces to measure against. It still authors the neutral family // authored surfaces to measure against. It nevertheless authors the
// instead of deriving it: deriving picked up the platform accent's chroma, // semantic family, while the neutral family stays unauthored on purpose:
// which painted neutral chrome blue on a default Linux scheme. // the palette file is chosen from the theme's scheme, which is always light
// on Qt < 6.5 and can be unknown on newer Qt, so one fixed ladder would be
// invisible against a platform palette of the other scheme.
for (const bool dark : {false, true}) { for (const bool dark : {false, true}) {
const QMap<AppColor::Role, QColor> system = shippedAppColors(QStringLiteral("System"), dark); const QMap<AppColor::Role, QColor> system = shippedAppColors(QStringLiteral("System"), dark);
EXPECT_TRUE(system.contains(AppColor::PositiveStrong)); EXPECT_TRUE(system.contains(AppColor::PositiveStrong));
EXPECT_TRUE(system.contains(AppColor::NegativeSoft)); EXPECT_TRUE(system.contains(AppColor::NegativeSoft));
for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}) { for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}) {
ASSERT_TRUE(system.contains(role)) << (dark ? "dark" : "light") << static_cast<int>(role); EXPECT_FALSE(system.contains(role)) << (dark ? "dark" : "light") << static_cast<int>(role);
EXPECT_EQ(system.value(role).hsvSaturation(), 0) << (dark ? "dark" : "light") << static_cast<int>(role);
}
// Strong is the loudest level a neutral family can reach: the darkest
// tone on the light scheme, the palest on the dark one. Quiet recedes
// toward the surface on both.
const double secondary = ThemeIconInk::relativeLuminance(system.value(AppColor::Icon));
const double primary = ThemeIconInk::relativeLuminance(system.value(AppColor::IconStrong));
const double quiet = ThemeIconInk::relativeLuminance(system.value(AppColor::IconSoft));
if (dark) {
EXPECT_GT(primary, secondary) << "dark";
EXPECT_GT(secondary, quiet) << "dark";
} else {
EXPECT_LT(primary, secondary) << "light";
EXPECT_LT(secondary, quiet) << "light";
} }
} }
} }