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);