From 497c230b035c44803518833f7370c99049f42fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:20:29 +0100 Subject: [PATCH] [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. --- .../palette_editor/palette_generator.cpp | 10 +- cockatrice/src/interface/theme_icon.cpp | 31 +- cockatrice/src/interface/theme_icon_ink.cpp | 170 +++++-- cockatrice/src/interface/theme_icon_ink.h | 66 ++- cockatrice/src/interface/theme_manager.cpp | 9 +- .../themes/Fabric/palette-default-dark.toml | 7 +- .../themes/Fabric/palette-default-light.toml | 7 +- .../themes/Fusion/palette-default-dark.toml | 11 +- .../themes/Fusion/palette-default-light.toml | 11 +- .../themes/Leather/palette-default-dark.toml | 7 +- .../themes/Leather/palette-default-light.toml | 7 +- .../themes/Plasma/palette-default-dark.toml | 7 +- .../themes/Plasma/palette-default-light.toml | 7 +- .../VelvetMarble/palette-default-dark.toml | 7 +- .../VelvetMarble/palette-default-light.toml | 7 +- .../theme_icon_render_test.cpp | 448 +++++++++++++----- 16 files changed, 587 insertions(+), 225 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index 345b96c17..d5d70c4ef 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -170,11 +170,13 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch 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. - const QColor iconInk = ThemeIconInk::glyphInk(winText, bg_win, hl); + // 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(hl, bg_win, IconEmphasis::Primary); - cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, bg_win, IconEmphasis::Quiet); + 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. for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 4b643cc84..cb37f6b77 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -73,6 +73,22 @@ ThemeIconFill::Scheme activeScheme() : 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. */ @@ -390,7 +406,7 @@ QColor IconTint::resolve(QIcon::Mode mode) const 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 theme authors one color per emphasis level, so the paint path // uses the resolved role verbatim. The filled treatments only shape the @@ -412,13 +428,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 resolves to the theme's authored icon ink. Semantic - // roles and the selected-row swap keep the color the palette gave them. - if (group == QPalette::Active && (effective == QPalette::WindowText || effective == QPalette::Text)) { - return themeManager->appColor(emphasisRole(AppColor::Icon, 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) diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp index f74dc2010..0b5364674 100644 --- a/cockatrice/src/interface/theme_icon_ink.cpp +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -12,6 +12,16 @@ 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 is the most chromatic member of its family. Contrast cannot @@ -21,11 +31,21 @@ constexpr double LABEL_CONTRAST_FLOOR = 4.5; // 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; -// 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; +// 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() @@ -107,11 +157,46 @@ double labelContrastFloor() 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 surfaceLuminance > 0.5 ? (surfaceLuminance + 0.05) / ratio - 0.05 : (surfaceLuminance + 0.05) * ratio - 0.05; + 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. Returns an invalid color when even full + * value cannot reach the floor at that saturation. + */ +QColor solveValue(int hue, int saturation, const QColor &surface, double floor) +{ + const double target = targetLuminance(surface, floor + CONTRAST_ROUNDING_MARGIN); + if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, 255)) < target) { + return {}; + } + int low = 0; + int high = 255; + for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { + const int middle = (low + high) / 2; + if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, middle)) >= target) { + high = middle; + } else { + low = middle; + } + } + return QColor::fromHsv(hue, saturation, high).toRgb(); } /** @@ -129,28 +214,35 @@ QColor chromaticStep(const QColor &color, const QColor &surface, double floor, i return ThemeIconInk::reachContrast(color, surface, floor); } - const double target = targetLuminance(surface, floor); const int hue = color.hue(); const int secondary = color.hsvSaturation(); - const int primary = qMin(255, secondary + stepPoints); - for (int saturation = primary; saturation > secondary; --saturation) { - if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, 255)) < target) { - continue; + for (int saturation = qMin(255, secondary + stepPoints); saturation > secondary; --saturation) { + const QColor solved = solveValue(hue, saturation, surface, floor); + if (solved.isValid()) { + return solved; } - int low = 0; - int high = 255; - for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { - const int middle = (low + high) / 2; - if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, middle)) >= target) { - high = middle; - } else { - low = middle; - } - } - return QColor::fromHsv(hue, saturation, high).toRgb(); } 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) @@ -159,23 +251,23 @@ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emph case IconEmphasis::Primary: 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; } +QColor disabledInk(const QColor &quiet, const QColor &surface) +{ + return reachContrast(blend(quiet, surface, DISABLED_BLEND), surface, DISABLED_CONTRAST_FLOOR).toRgb(); +} + double primaryContrastFloor() { return LABEL_CONTRAST_FLOOR; } -double quietBlend() -{ - return QUIET_BLEND; -} - double quietContrastFloor() { return QUIET_CONTRAST_FLOOR; diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h index f1af04184..760c3b8de 100644 --- a/cockatrice/src/interface/theme_icon_ink.h +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -32,6 +32,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 +75,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. @@ -99,14 +121,12 @@ double labelContrastFloor(); /** * @brief Applies an emphasis level to an already-hued glyph color. * - * Secondary keeps the color untouched, so it is the no-op the default needs. - * Primary is the most chromatic member of its family: it raises the saturation - * to the most that still clears the text floor. Contrast alone cannot order the - * ladder, because on a dark surface the highest-contrast tone is the palest one; - * a primary that reads as a washed-out version of the secondary is exactly the - * inversion this rule removes. 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. @@ -115,13 +135,27 @@ double labelContrastFloor(); */ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis); -/** @brief Contrast floor the primary level reaches, so it stays legible. */ +/** + * @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 diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 6b333d4cf..90e6f8e8c 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -660,12 +660,13 @@ QColor ThemeManager::appColor(AppColor::Role role) const // 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 surface = palette.color(QPalette::Active, QPalette::Window); - const QColor ink = - ThemeIconInk::glyphInk(palette.color(QPalette::Active, QPalette::WindowText), surface, accent); + 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); switch (role) { case AppColor::IconStrong: - return ThemeIconInk::forEmphasis(accent, surface, IconEmphasis::Primary); + return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary); case AppColor::IconSoft: return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Quiet); case AppColor::Icon: diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index 916a27ea3..bd7945be9 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -73,11 +73,10 @@ AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 Icon = #7ca0e5 IconStrong = #6a9dff -IconSoft = #70809d +IconSoft = #93a1ba Positive = #5bb174 PositiveStrong = #30b758 -PositiveSoft = #5e8873 +PositiveSoft = #84a98f Negative = #ed8077 NegativeStrong = #ff7367 -NegativeSoft = #9b7678 - +NegativeSoft = #be9794 \ No newline at end of file diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index 8a27f4c30..2b10981e7 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -73,11 +73,10 @@ AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 Icon = #2f58ac IconStrong = #054ddf -IconSoft = #687899 +IconSoft = #505c76 Positive = #166931 PositiveStrong = #006b22 -PositiveSoft = #5d7f6f +PositiveSoft = #40634b Negative = #b0251d NegativeStrong = #ba0a00 -NegativeSoft = #9a6c6f - +NegativeSoft = #7c504d \ No newline at end of file diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index 037eab5d0..b367fe12b 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -71,13 +71,12 @@ Accent = #ff1e1e1e [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 -Icon = #5eb97d -IconStrong = #30be61 -IconSoft = #6e8d79 +Icon = #74ace4 +IconStrong = #58acff +IconSoft = #95aabf Positive = #5fb97a PositiveStrong = #32bf5c -PositiveSoft = #698e74 +PositiveSoft = #8ab196 Negative = #f8867c NegativeStrong = #ff8075 -NegativeSoft = #a37c78 - +NegativeSoft = #c79e9b \ No newline at end of file diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index cdf5a6a7f..89439d145 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -71,13 +71,12 @@ Accent = #fff0f0f0 [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 -Icon = #107d36 -IconStrong = #007e2c -IconSoft = #6c937a +Icon = #196dc2 +IconStrong = #006ad4 +IconSoft = #546f8a Positive = #1b7d3a PositiveStrong = #007e28 -PositiveSoft = #70927b +PositiveSoft = #4c765a Negative = #cf2c23 NegativeStrong = #da0c00 -NegativeSoft = #b37d7b - +NegativeSoft = #935f5c \ No newline at end of file diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index a613da217..479463a38 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -73,11 +73,10 @@ AccentStrong = #b7823b AccentSoft = #e4c58f Icon = #bd8d4a IconStrong = #d0851e -IconSoft = #86745a +IconSoft = #a79479 Positive = #55a66d PositiveStrong = #2dab52 -PositiveSoft = #588061 +PositiveSoft = #7c9f86 Negative = #df7970 NegativeStrong = #ff5e50 -NegativeSoft = #996b66 - +NegativeSoft = #b38e8b \ No newline at end of file diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index 4469c127b..a2e9b8bf3 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -73,11 +73,10 @@ AccentStrong = #b7823b AccentSoft = #e4c58f Icon = #725025 IconStrong = #7e4b09 -IconSoft = #817059 +IconSoft = #625544 Positive = #15642e PositiveStrong = #006621 -PositiveSoft = #61795d +PositiveSoft = #3d5f48 Negative = #a7231c NegativeStrong = #b10a00 -NegativeSoft = #9a665b - +NegativeSoft = #764c4a \ No newline at end of file diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index ee6f9d254..ce8d84a09 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -73,11 +73,10 @@ AccentStrong = #5b3ee0 AccentSoft = #a28bf7 Icon = #9380eb IconStrong = #927aff -IconSoft = #736b97 +IconSoft = #928cb0 Positive = #519f69 PositiveStrong = #2ba44f -PositiveSoft = #517a63 +PositiveSoft = #779881 Negative = #d5736b NegativeStrong = #ff4f40 -NegativeSoft = #926567 - +NegativeSoft = #ab8885 \ No newline at end of file diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index b3e96ca75..d1c973f70 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -73,11 +73,10 @@ AccentStrong = #5b3ee0 AccentSoft = #a28bf7 Icon = #5338ce IconStrong = #4118fe -IconSoft = #7569a4 +IconSoft = #585078 Positive = #15622e PositiveStrong = #006420 -PositiveSoft = #5c776d +PositiveSoft = #3c5d47 Negative = #a4231b NegativeStrong = #ae0900 -NegativeSoft = #95646d - +NegativeSoft = #744b48 \ No newline at end of file diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index b5b43df0a..69b5f3953 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -73,11 +73,10 @@ AccentStrong = #54687e AccentSoft = #9db0c4 Icon = #8493a2 IconStrong = #6b95bf -IconSoft = #6d737a +IconSoft = #8c9399 Positive = #52a16a PositiveStrong = #2ba650 -PositiveSoft = #517c61 +PositiveSoft = #789a82 Negative = #d8756c NegativeStrong = #ff5344 -NegativeSoft = #926865 - +NegativeSoft = #ad8a86 \ No newline at end of file diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index c61f1e94c..44626ff46 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -73,11 +73,10 @@ AccentStrong = #54687e AccentSoft = #9db0c4 Icon = #495a6e IconStrong = #375a84 -IconSoft = #6e7580 +IconSoft = #525961 Positive = #16662f PositiveStrong = #006721 -PositiveSoft = #5b7b6a +PositiveSoft = #3e6049 Negative = #aa241c NegativeStrong = #b40a00 -NegativeSoft = #96696b - +NegativeSoft = #784d4b \ No newline at end of file diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 25253a29d..b8eff93fb 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -86,9 +86,10 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) */ QColor expectedNeutralInk(const QPalette &palette) { - return ThemeIconInk::glyphInk(palette.color(QPalette::Active, QPalette::WindowText), - palette.color(QPalette::Active, QPalette::Window), - palette.color(QPalette::Active, QPalette::Highlight)); + 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); + return ThemeIconInk::glyphInk(text, surface, palette.color(QPalette::Active, QPalette::Highlight)); } /** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ @@ -275,17 +276,29 @@ TEST_F(ThemeIconRenderTest, AppColorTintUsesSemanticColorNotTheAccent) EXPECT_EQ(countOpaquePixels(negativePixmap, accent), 0); } -/** @brief A disabled control must not keep signalling its semantic color. */ -TEST_F(ThemeIconRenderTest, DisabledAppColorUsesDisabledForeground) +/** + * @brief A disabled glyph recedes past the quiet level, whatever the family. + * + * The palette's own disabled text is a fixed gray, so on the darkest themes it + * out-reads the quiet level and an enabled glyph looks more disabled than a + * disabled one. The disabled ink is derived from the quiet ink instead, and a + * semantic family loses its hue when disabled. + */ +TEST_F(ThemeIconRenderTest, DisabledGlyphRecedesPastTheQuietLevel) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor quiet = themeManager->appColor(AppColor::IconSoft); + ASSERT_TRUE(quiet.isValid()); + const QPalette palette = qApp->palette(); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), quiet); + const QColor expected = ThemeIconInk::disabledInk(quiet, surface); - const IconTint tint(AppColor::Negative); - EXPECT_EQ(tint.resolve(QIcon::Disabled), disabledText); - EXPECT_NE(tint.resolve(QIcon::Normal), disabledText); + for (const AppColor::Role role : {AppColor::Icon, AppColor::Positive, AppColor::Negative}) { + const QColor disabled = IconTint(role).resolve(QIcon::Disabled); + EXPECT_EQ(disabled, expected) << static_cast(role); + EXPECT_NE(disabled, themeManager->appColor(role)) << static_cast(role); + } + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(expected, surface), 1.0); } /** @brief Palette tints keep the WindowText -> HighlightedText selected swap. */ @@ -586,10 +599,8 @@ TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath) /** @brief Disabled keeps the shape language but drops back to the line glyph. */ TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled); + ASSERT_TRUE(disabled.isValid()); const QColor tone = themeManager->appColor(AppColor::Positive); const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); @@ -599,9 +610,9 @@ TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) // A filled disc at full mass would not read as unavailable, so Disabled // drops the badge: no ring, no disc, just the disabled line glyph. EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); - EXPECT_EQ(countOpaquePixels(pixmap, disabledText.darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, disabled.darker(300)), 0); EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0); - EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabled), 0); } /** @@ -733,10 +744,8 @@ TEST_F(ThemeIconRenderTest, StickerOnAuthoredArtUsesTheAssetPath) /** @brief Disabled keeps the shape language but drops back to the line glyph. */ TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled); + ASSERT_TRUE(disabled.isValid()); const QColor tone = themeManager->appColor(AppColor::Negative); const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); @@ -746,7 +755,7 @@ TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) // No rim in the sticker tone: Disabled drops the treatment and paints the // thin disabled line glyph instead. EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); - EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabled), 0); } /** @brief Line stays the default: no disc, no ring, just the recolored glyph. */ @@ -1204,6 +1213,44 @@ TEST(IconInk, GivesNeutralGlyphsAccentChroma) EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()); } +/** + * @brief A medium-light surface needs the pole that can actually reach the floor. + * + * #9a9a9a is lighter than mid-gray but still too light for white to reach a text + * ratio, so "light background means move toward black" is the wrong test. + */ +TEST(IconInk, ContrastFloorPicksThePoleItCanReach) +{ + const QColor background(QStringLiteral("#9a9a9a")); + ASSERT_LT(ThemeIconInk::contrastRatio(QColor(Qt::white), background), 4.5); + ASSERT_GE(ThemeIconInk::contrastRatio(QColor(Qt::black), background), 4.5); + + const QColor solved = ThemeIconInk::reachContrast(QColor(QStringLiteral("#d0d0d0")), background, 4.5); + EXPECT_GE(ThemeIconInk::contrastRatio(solved, background), 4.5); +} + +/** + * @brief A green or red accent must not tint neutral chrome. + * + * Positive owns green and Negative owns red, so a theme whose accent sits in + * either band would otherwise paint "settings" the same color as "add". + */ +TEST(IconInk, SemanticAccentDoesNotTintNeutralChrome) +{ + const QColor surface(QStringLiteral("#18202e")); + const QColor text = Qt::white; + const QColor green(QStringLiteral("#149740")); + const QColor blue(QStringLiteral("#2f6fd6")); + + ASSERT_TRUE(ThemeIconInk::isSemanticHue(green.hue())); + ASSERT_FALSE(ThemeIconInk::isSemanticHue(blue.hue())); + + const QColor greenInk = ThemeIconInk::glyphInk(text, surface, green); + EXPECT_NEAR(greenInk.hue(), ThemeIconInk::neutralChromeHue(), 2); + EXPECT_FALSE(ThemeIconInk::isSemanticHue(greenInk.hue())); + EXPECT_NEAR(ThemeIconInk::glyphInk(text, surface, blue).hue(), blue.hue(), 1); +} + TEST(IconInk, MeetsTheLabelFloorOnEveryShippedPalette) { const QString themesRoot = @@ -1464,8 +1511,15 @@ TEST_F(ThemeIconRenderTest, PrimaryIsTheMostChromaticLevelOnBothSchemes) } } -/** @brief A quiet glyph recedes toward its surface without dropping below 3:1. */ -TEST_F(ThemeIconRenderTest, QuietEmphasisRecedesButStaysLegible) +/** + * @brief Quiet is the least chromatic level; it shares the legibility floor. + * + * Ordering the ladder by contrast inverted it on dark schemes and pushed quiet + * under the disabled ink. Quiet now keeps the same floor as the other levels and + * recedes by carrying less of the family's chroma, which also leaves the + * disabled level the only one below the floor. + */ +TEST_F(ThemeIconRenderTest, QuietIsTheLeastChromaticLevel) { for (const bool dark : {false, true}) { const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); @@ -1474,10 +1528,12 @@ TEST_F(ThemeIconRenderTest, QuietEmphasisRecedesButStaysLegible) const QColor quiet = palette.roles.value(AppColor::IconSoft); ASSERT_TRUE(secondary.isValid() && quiet.isValid()) << (dark ? "dark" : "light"); + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); for (const QColor &surface : {palette.window, palette.button}) { - EXPECT_LT(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::contrastRatio(secondary, surface)) - << "the quiet glyph does not recede"; - EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::quietContrastFloor()) + const double quietContrast = ThemeIconInk::contrastRatio(quiet, surface); + EXPECT_GE(quietContrast, ThemeIconInk::quietContrastFloor()) << (dark ? "dark" : "light"); + EXPECT_GE(quietContrast - ThemeIconInk::contrastRatio(ThemeIconInk::disabledInk(quiet, surface), surface), + 1.0) << (dark ? "dark" : "light"); } } @@ -1564,16 +1620,18 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) << theme.toStdString() << (dark ? " dark" : " light"); EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor()) << theme.toStdString() << (dark ? " dark" : " light"); - EXPECT_LT(quiet, secondary) << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(palette.roles.value(AppColor::IconSoft).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); for (const auto &[base, strong, soft] : {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { - const double baseContrast = ThemeIconInk::contrastRatio(palette.roles.value(base), surface); EXPECT_GE(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), ThemeIconInk::quietContrastFloor()) << theme.toStdString() << (dark ? " dark" : " light"); - EXPECT_LT(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), baseContrast) + EXPECT_LT(palette.roles.value(soft).hsvSaturation(), + palette.roles.value(base).hsvSaturation()) << theme.toStdString() << (dark ? " dark" : " light"); EXPECT_GT(palette.roles.value(strong).hsvSaturation(), palette.roles.value(base).hsvSaturation()) << theme.toStdString() << (dark ? " dark" : " light"); @@ -1593,7 +1651,86 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) EXPECT_FALSE(system.contains(AppColor::IconSoft)); } -/** @brief Every emphasis keeps the selected-row swap to HighlightedText. */ +/** + * @brief Neutral chrome keeps a hue distance from the semantic families. + * + * Fusion's accent is green, so deriving neutral ink from it painted every menu + * and toolbar glyph the same color as the Positive family. The authored neutral + * family has to stay a hue a reader can tell apart from "add" and "remove". + */ +TEST(IconInk, NeutralFamilyKeepsItsDistanceFromTheSemanticFamilies) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; + } + const QColor neutral = palette.roles.value(AppColor::Icon); + for (const AppColor::Role role : {AppColor::Positive, AppColor::Negative}) { + const QColor semantic = palette.roles.value(role); + ASSERT_TRUE(neutral.isValid() && semantic.isValid()) + << theme.toStdString() << (dark ? " dark" : " light"); + const int distance = qAbs(neutral.hue() - semantic.hue()); + EXPECT_GE(qMin(distance, 360 - distance), 30) + << theme.toStdString() << (dark ? " dark" : " light") << static_cast(role); + ++checked; + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +/** + * @brief Every shipped theme keeps the disabled level below the quiet level. + * + * The palette's disabled text is a fixed gray, so this is the invariant the + * derived disabled ink exists to hold; a regression here makes an enabled quiet + * glyph look unavailable. + */ +TEST(IconInk, DisabledStaysBelowQuietOnEveryShippedTheme) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; + } + const QColor quiet = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(quiet.isValid()) << theme.toStdString() << (dark ? " dark" : " light"); + const QColor surface = ThemeIconInk::harderSurface(palette.window, palette.button, quiet); + const QColor disabled = ThemeIconInk::disabledInk(quiet, surface); + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - + ThemeIconInk::contrastRatio(disabled, surface), + 1.0) + << theme.toStdString() << (dark ? " dark" : " light"); + + for (const QColor &anySurface : {palette.window, palette.button}) { + EXPECT_LT(ThemeIconInk::contrastRatio(disabled, anySurface), + ThemeIconInk::contrastRatio(quiet, anySurface)) + << theme.toStdString() << (dark ? " dark" : " light"); + } + ++checked; + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +/** + * @brief Every emphasis keeps the selected-row swap to HighlightedText. + */ TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText) { const QColor tone = IconTint(AppColor::AccentSoft).resolve(QIcon::Normal); @@ -1612,12 +1749,13 @@ TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText) } /** - * @brief Writes the authored emphasis ladder when asked; skipped otherwise. + * @brief Writes the emphasis ladder sheets for every shipped palette theme. * - * One sheet per scheme showing what a theme author actually stores: the neutral, - * positive and negative families, each at its quiet, secondary and primary - * level. The paint path is a lookup, so this is the visual proof that the - * palette rows and the rendered glyphs agree. + * One sheet per theme and scheme: the neutral, positive and negative families at + * their quiet, secondary and primary levels, each as a line glyph, a sticker and + * a badge, three glyphs per family, at 16 and 24 px, on the window and button + * surfaces. The tone is the one the emphasis rule resolves for that surface, so + * the sheet is what the engine paints rather than a hand-picked sample. */ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) { @@ -1628,92 +1766,171 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) ASSERT_TRUE(QDir().mkpath(outDir)); constexpr int MARGIN = 24; - constexpr int HEADER_HEIGHT = 34; - constexpr int ROW_HEIGHT = 38; - constexpr int ICON_SIZE = 20; + constexpr int LABEL_WIDTH = 230; + constexpr int CELL_WIDTH = 166; + constexpr int ROW_HEIGHT = 58; + constexpr int PANEL_TITLE = 26; + constexpr int COLUMN_HEADER = 20; + constexpr int PANEL_PADDING = 12; + constexpr int GLYPH_SLOT = 32; constexpr qreal DPR = 2.0; - constexpr int WIDTH = 760; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; struct Family { const char *name; - const char *glyph; + const char *glyphs[3]; AppColor::Role quiet; AppColor::Role secondary; AppColor::Role primary; }; - struct Sample - { - const char *level; - AppColor::Role role; - }; - const QList families = { - {"Neutral", "settings", AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, - {"Positive", "increment", AppColor::PositiveSoft, AppColor::Positive, AppColor::PositiveStrong}, - {"Negative", "decrement", AppColor::NegativeSoft, AppColor::Negative, AppColor::NegativeStrong}, + {"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, + {"Positive", + {"increment", "ready_start", "arrow_up"}, + AppColor::PositiveSoft, + AppColor::Positive, + AppColor::PositiveStrong}, + {"Negative", + {"decrement", "not_ready_start", "remove_row"}, + AppColor::NegativeSoft, + AppColor::Negative, + AppColor::NegativeStrong}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), }; - for (const bool dark : {false, true}) { - const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); - ASSERT_TRUE(palette.text.isValid()); - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark)); - - const int logicalHeight = MARGIN + 30 + families.size() * (HEADER_HEIGHT + 3 * ROW_HEIGHT) + MARGIN; - QImage image(QSize(qRound(WIDTH * DPR), qRound(logicalHeight * DPR)), QImage::Format_ARGB32_Premultiplied); - image.fill(palette.window); - QPainter painter(&image); + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing, true); - painter.setRenderHint(QPainter::SmoothPixmapTransform, true); - painter.scale(DPR, DPR); + renderer.render(&painter); + return pixmap; + }; - qreal y = MARGIN; - painter.setPen(palette.text); - painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); - painter.drawText(QPointF(MARGIN, y), (dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")) + - QStringLiteral(" — authored emphasis ladder (Fabric)")); - y += 30; + const int panelWidth = LABEL_WIDTH + static_cast(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING; + const int panelHeight = + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 3 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + ASSERT_TRUE(palette.text.isValid()) << theme.toStdString(); + + // The emphasis rule reads the live palette, so apply the theme + // before resolving tones for the sheet. + ASSERT_TRUE(applyShippedPalette(theme, dark)); + + // The paint path is a lookup now, so the sheet shows the authored + // rows verbatim rather than a color recomputed from the accent. + const auto toneFor = [&](const Family &family, int level) { + return level == 0 ? palette.roles.value(family.quiet) + : level == 1 ? palette.roles.value(family.secondary) + : palette.roles.value(family.primary); + }; + const auto scheme = dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); - for (const Family &family : families) { painter.setPen(palette.text); painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); - painter.drawText(QPointF(MARGIN, y), QString::fromLatin1(family.name)); - y += HEADER_HEIGHT; + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, emphasis across treatments") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); - const QList samples = { - {"quiet", family.quiet}, - {"secondary", family.secondary}, - {"primary", family.primary}, + struct Surface + { + const char *name; + QColor background; + QColor text; + }; + const QList surfaces = { + {"Window surface", palette.window, palette.text}, + {"Button surface", palette.button, palette.text}, }; - for (const Sample &sample : samples) { - const QColor ink = palette.roles.value(sample.role); - ASSERT_TRUE(ink.isValid()) << family.name << " " << sample.level; - const QByteArray svg = readIconSvg(family.glyph); - ASSERT_FALSE(svg.isEmpty()) << family.glyph; - QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); - QPixmap glyph(QSize(ICON_SIZE, ICON_SIZE)); - glyph.fill(Qt::transparent); - QPainter glyphPainter(&glyph); - glyphPainter.setRenderHint(QPainter::Antialiasing, true); - renderer.render(&glyphPainter); - glyphPainter.end(); - painter.drawPixmap(QPointF(MARGIN + 8, y - ICON_SIZE + 4), glyph); + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); - painter.setPen(palette.text); - painter.setFont(QFont(QStringLiteral("Sans"), 10)); - painter.drawText(QPointF(MARGIN + ICON_SIZE + 24, y), QString::fromLatin1(sample.level)); - painter.setPen(palette.text); - painter.setFont(QFont(QStringLiteral("Mono"), 9)); - painter.drawText(QPointF(MARGIN + 200, y), ink.name(QColor::HexRgb)); - y += ROW_HEIGHT; + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); + } + + qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; + for (const Family &family : families) { + for (int level = 0; level < 3; ++level) { + const QColor ink = toneFor(family, level); + ASSERT_TRUE(ink.isValid()) + << theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level]; + + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, y + 14), + QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), + QString::fromLatin1(LEVEL_NAMES[level]))); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal cellX = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + QPixmap pixmap; + if (treatment == 0) { + pixmap = renderLine(svg, ink, size); + } else if (treatment == 1) { + pixmap = ThemeIconSticker::renderSticker(svg, ink, size); + } else { + pixmap = ThemeIconBadge::renderBadge(svg, ink, size, scheme); + } + const qreal x = cellX + 6 + static_cast(glyphIndex) * GLYPH_SLOT + + (GLYPH_SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; } - } - painter.end(); - const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("emphasis-preview-dark.png") - : QStringLiteral("emphasis-preview-light.png")); - ASSERT_TRUE(image.save(path)) << path.toStdString(); + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("emphasis-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } } } @@ -1724,7 +1941,9 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) * each sheet renders the same three authored levels as line glyphs, as sticker * fills and as badges, at the two sizes the UI uses, on the two surfaces the * contrast targets are measured against. Each family carries three glyphs, so - * the sheet also proves the level, not just one lucky shape. + * the sheet also proves the level, not just one lucky shape. A disabled row + * closes each family: it must read dimmer than quiet, and every treatment falls + * back to the line glyph there. * * The authored tone is the top of every filled gradient, so the sheet prints that * tone and the darker bottom the shape actually carries. @@ -1792,7 +2011,7 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) const int panelWidth = LABEL_WIDTH + static_cast(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING; const int panelHeight = - PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 3 * ROW_HEIGHT + PANEL_PADDING; + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 4 * ROW_HEIGHT + PANEL_PADDING; const int logicalWidth = 2 * MARGIN + panelWidth; const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; @@ -1800,6 +2019,12 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) for (const bool dark : {false, true}) { const InkPalette palette = shippedPalette(theme, dark); ASSERT_TRUE(palette.text.isValid()) << theme.toStdString(); + const QColor quietTone = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(quietTone.isValid()) << theme.toStdString(); + // What the engine paints for `QIcon::Disabled`: the authored quiet + // level receded past its floor, measured on the harder surface. + const QColor disabledTone = ThemeIconInk::disabledInk( + quietTone, ThemeIconInk::harderSurface(palette.window, palette.button, quietTone)); QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), QImage::Format_ARGB32_Premultiplied); @@ -1846,21 +2071,22 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; for (const Family &family : families) { - for (int level = 0; level < 3; ++level) { - const AppColor::Role role = roleAt(family, level); - const QColor ink = palette.roles.value(role); + for (int level = 0; level < 4; ++level) { + const bool disabled = level == 3; + const QColor ink = disabled ? disabledTone : palette.roles.value(roleAt(family, level)); + const QString levelName = + disabled ? QStringLiteral("Disabled") : QString::fromLatin1(LEVEL_NAMES[level]); ASSERT_TRUE(ink.isValid()) - << theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level]; - const QColor bottom = ThemeIconFill::bottomColor(ink); + << theme.toStdString() << " " << family.name << " " << levelName.toStdString(); + const QColor bottom = disabled ? ink : ThemeIconFill::bottomColor(ink); painter.setPen(surface.text); painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); painter.drawText(QPointF(contentLeft, y + 14), - QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), - QString::fromLatin1(LEVEL_NAMES[level]))); + QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), levelName)); painter.setFont(QFont(QStringLiteral("Mono"), 7)); painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); - if (bottom != ink) { + if (!disabled && bottom != ink) { painter.drawText(QPointF(contentLeft + 82, y + 28), QStringLiteral("bottom ") + bottom.name(QColor::HexRgb)); } @@ -1875,7 +2101,7 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; QPixmap pixmap; - if (treatment == 0) { + if (disabled || treatment == 0) { pixmap = renderLine(svg, ink, size); } else if (treatment == 1) { pixmap = ThemeIconSticker::renderSticker(svg, ink, size);