From c7e842280887f7c7b89aa1c95164a4c3940916fc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:09:02 +0100 Subject: [PATCH] [Icons] Author icon emphasis colors as palette roles (#7789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Icons] Author icon emphasis colors as palette roles The emphasis levels were computed from the accent at paint time, so a theme could not vary them: every theme got the same derived strong and soft tones, and a semantic hue was pushed toward the accent whether the author wanted that or not. Give each family three authored roles instead. Icon/IconStrong/IconSoft carry the neutral glyph ink and Positive/Negative gain the same Strong and Soft companions. Emphasis becomes a lookup inside the family, and a caller that names a variant pins that level at every emphasis. The paint path returns the authored color verbatim; only the filled treatments keep their saturation floor, because carrying a disc or a fill is a treatment decision. Shipped palettes author the full ladder for both schemes; the palette editor exposes the new rows and the generator writes them. System keeps deriving its neutral ink from the platform palette, since it ships no palette of its own. * [Icons] Keep the authored emphasis in filled treatments The saturation floor was added while the emphasis levels were still computed: it rescued the pale dark-scheme tones once a whole shape carried them. With the levels authored, the floor overrides the author instead: a quiet level jumped to the secondary saturation as soon as a sticker or a badge painted it, so the ladder only existed for line glyphs. The authored tone is now the top of the fill gradient and the treatment only darkens the bottom for depth, so every treatment renders exactly the level the palette stores. The badge glyph ink no longer guesses the scheme from the tone's value: an authored quiet level is a dark color on the dark scheme too, so the scheme is passed in from the surface palette. The preview sheet renders every shipped palette theme, three glyphs per family, and each row prints the authored tone and the darker bottom. * [Icons] Order the emphasis ladder by chroma, not contrast Contrast cannot order the ladder. On a dark surface the highest-contrast tone is the palest one, so a 7:1 primary came out near-white while the secondary kept the family's color; the ladder read inverted, and for the shipped semantic greens and reds the primary level was already satisfied by the secondary, so the two levels resolved to the same hex. Emphasis is chromatic intensity now. Quiet recedes toward the surface at the 3:1 non-text floor, secondary keeps the family color at the 4.5:1 text floor, and primary is the most chromatic tone the same floor allows. Every level stays legible on the window and the button surface, and the primary is a strictly more saturated member of its family on both schemes. The reformatted palettes move the two upper levels onto the shared floor and leave the quiet level alone. * [Icons] Make the emphasis ladder structural on every theme The chroma ladder landed with four P1s from review: reachContrast picked its pole by background lightness instead of by what each pole can reach, the generated neutral ink was solved on the window surface only, Fusion's neutral family was still the green accent, and quiet sat below the text floor while disabled reused the palette gray. Pick the pole by achievable contrast, solve fallback and preview inks on the harder of the window and button surfaces, re-author Fusion's neutral trio in slate blue, and keep quiet on the shared text floor by muting its saturation to 45% instead of blending it into the surface. Disabled is now derived from the quiet ink, so it is structurally dimmer than every enabled level on every theme. Re-author all soft roles at a 4.60 target with a rounding margin, rebuild the per-theme preview generator the rebase dropped, and add a Disabled row to every treatment sheet. * [Icons] Render preview glyphs at the sheet's device pixel ratio The preview sheets paint on a 2x painter but rasterized each glyph at its logical size, so every icon was upscaled on the way into the file and the sheets read as blurry. Render each glyph at device size and tag the pixmap with the ratio, so one source pixel lands on one file pixel. * [Icons] Keep the System semantic ladder on the text floor System authors no palette of its own, so its semantic family is fixed data measured against whatever surfaces the platform provides. The ladder that landed with the authored roles reused the old contrast-ordered values: PositiveSoft and NegativeSoft sat at 2.76–3.90:1 on common light and dark platform surfaces, and the strong level was less saturated than the secondary, which is the inversion the chroma rule removes. Re-author the six default semantic colors per scheme at the same 4.60 target the shipped themes use, against representative platform surfaces, and mirror them in System's palette files. The new DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces test holds the floor and the saturation order there, and shippedPalette's early return now says why System is outside the authored-palette assertions. * [Icons] Apply the CI clang-format output to the new tests * [Icons] Adapt the treatment preview to the authored roles The preview file is inherited from the treatment PR. Here the palette already authors the fill tone and the badge takes an explicit scheme, so the generator stops re-saturating the role and passes the scheme the sheet renders. * [Icons] Author neutral System icon ink The System theme derived icon ink from the platform palette, which rendered blue under the default palette. Author Icon/IconStrong/IconSoft explicitly: a black ladder in light mode, a white ladder in dark mode. Pin authorship, achromaticity and ordering in the render test. * Address review findings --- .../palette_editor/palette_generator.cpp | 16 +- .../palette_editor/palette_grid_widget.cpp | 9 + cockatrice/src/interface/theme_config.cpp | 22 +- cockatrice/src/interface/theme_config.h | 26 +- cockatrice/src/interface/theme_icon.cpp | 108 +- cockatrice/src/interface/theme_icon.h | 53 +- cockatrice/src/interface/theme_icon_badge.cpp | 20 +- cockatrice/src/interface/theme_icon_badge.h | 17 +- cockatrice/src/interface/theme_icon_fill.cpp | 49 +- cockatrice/src/interface/theme_icon_fill.h | 45 +- cockatrice/src/interface/theme_icon_ink.cpp | 222 +++- cockatrice/src/interface/theme_icon_ink.h | 73 +- .../src/interface/theme_icon_sticker.cpp | 2 +- cockatrice/src/interface/theme_manager.cpp | 30 + .../themes/Fabric/palette-default-dark.toml | 12 +- .../themes/Fabric/palette-default-light.toml | 12 +- .../themes/Fusion/palette-default-dark.toml | 15 +- .../themes/Fusion/palette-default-light.toml | 15 +- .../themes/Leather/palette-default-dark.toml | 12 +- .../themes/Leather/palette-default-light.toml | 12 +- .../themes/Plasma/palette-default-dark.toml | 12 +- .../themes/Plasma/palette-default-light.toml | 12 +- .../themes/System/palette-default-dark.toml | 17 +- .../themes/System/palette-default-light.toml | 17 +- .../VelvetMarble/palette-default-dark.toml | 12 +- .../VelvetMarble/palette-default-light.toml | 12 +- .../icon_treatment_preview_test.cpp | 11 +- .../theme_icon_render_test.cpp | 953 ++++++++++++++---- .../theme_migration/theme_migration_test.cpp | 49 +- 29 files changed, 1448 insertions(+), 417 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index 6bcfdc713..d5d70c4ef 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -1,6 +1,7 @@ #include "palette_generator.h" #include "../theme_config.h" +#include "../theme_icon_ink.h" #include #include @@ -167,10 +168,21 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch // the lightened, desaturated companion used for button-gradient highlights. cfg.appColors[AppColor::AccentStrong] = hl; cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // Neutral icon ink follows the generated accent at the same three emphasis + // levels a hand-authored theme stores, so the preview matches what the icon + // engine renders before the palette is saved. The harder of the two surfaces + // is the one the floor has to be solved against. + const QColor iconSurface = ThemeIconInk::harderSurface(bg_win, bg_btn, winText); + const QColor iconInk = ThemeIconInk::glyphInk(winText, iconSurface, hl); + cfg.appColors[AppColor::Icon] = iconInk; + cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary); + cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet); // Semantic roles stay independent of the accent: a generated palette must // still paint add/ready green and remove/not-ready red. - cfg.appColors[AppColor::Positive] = defaultAppColor(AppColor::Positive, dark); - cfg.appColors[AppColor::Negative] = defaultAppColor(AppColor::Negative, dark); + for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, + AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + cfg.appColors[role] = defaultAppColor(role, dark); + } // BrightText QColor bright; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 3fec389da..73e821b90 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -65,10 +65,19 @@ static const QMap APP_ROLE_DESCRIPTIONS = { QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")}, {AppColor::AccentSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::Icon, QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink at the default action weight")}, + {AppColor::IconStrong, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for the one action a surface exists for")}, + {AppColor::IconSoft, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for a glyph that labels rather than acts")}, {AppColor::Positive, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")}, + {AppColor::PositiveStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at primary emphasis")}, + {AppColor::PositiveSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at quiet emphasis")}, {AppColor::Negative, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, + {AppColor::NegativeStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at primary emphasis")}, + {AppColor::NegativeSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at quiet emphasis")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 42d7a4691..848be2231 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -458,12 +458,30 @@ QColor defaultAppColor(AppColor::Role role, bool dark) switch (role) { case AppColor::AccentStrong: case AppColor::AccentSoft: - // Derived from the palette highlight by ThemeManager::appColor(). + // The neutral icon family is palette-derived like the accent: a theme + // that leaves it unauthored (System, and every theme written before the + // roles existed) keeps the live platform palette's WindowText instead + // of pinning one scheme's ink. + case AppColor::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: return {}; + // The semantic family follows the same chroma ladder as the authored + // themes. A theme that authors none of it has no per-surface palette to + // measure against, so these are authored against the representative + // platform surfaces the System theme runs on. case AppColor::Positive: - return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b")); + return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1a7d3a")); + case AppColor::PositiveStrong: + return QColor(dark ? QStringLiteral("#32c05c") : QStringLiteral("#007e28")); + case AppColor::PositiveSoft: + return QColor(dark ? QStringLiteral("#8ab196") : QStringLiteral("#4c7659")); case AppColor::Negative: return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e")); + case AppColor::NegativeStrong: + return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00")); + case AppColor::NegativeSoft: + return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c")); } return {}; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 7d05e4bf0..68734e762 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -13,6 +13,13 @@ // set. Stored in the same palette-.toml under an [AppColors] section // and editable from the palette editor, so theme authors can control colors // beyond what Qt's palette can express. +// +// The icon families carry one role per emphasis level. Icon is the neutral +// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive +// and Negative follow the same pattern, so an emphasis never has to be computed +// from a hue at paint time: the theme authors all three levels. Naming a variant +// explicitly (`IconStrong`) pins that level; naming the family (`Icon`) lets the +// caller's emphasis choose. namespace AppColor { Q_NAMESPACE @@ -20,8 +27,15 @@ enum Role { AccentStrong, AccentSoft, + Icon, + IconStrong, + IconSoft, Positive, + PositiveStrong, + PositiveSoft, Negative, + NegativeStrong, + NegativeSoft, }; Q_ENUM_NS(Role) } // namespace AppColor @@ -47,9 +61,15 @@ Q_ENUM_NS(Role) * @brief Fallback for an application color role when the active theme does not * define one. * - * Accent roles return an invalid QColor because ThemeManager derives them from - * the palette highlight. Semantic roles return an opaque default that suits the - * scheme, so themes written before the role existed still render it. + * Accent roles and the neutral Icon family return an invalid QColor because + * ThemeManager derives them from the palette: the accent from the highlight, + * the icon ink from the live WindowText held to the label floor. Semantic roles + * return an opaque default that suits the scheme, so themes written before the + * role existed still render it. The System theme deliberately leaves the + * neutral family unauthored: the palette file it picks cannot know the platform + * scheme, so a fixed ladder would be invisible on the other one. It derives + * neutral ink from the live platform palette instead of pinning one scheme's + * colors. */ QColor defaultAppColor(AppColor::Role role, bool dark); diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 3557a9441..f0c9ef84f 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -2,7 +2,6 @@ #include "libcockatrice/utility/svg_recolor.h" #include "theme_icon_badge.h" -#include "theme_icon_fill.h" #include "theme_icon_ink.h" #include "theme_icon_sticker.h" #include "theme_manager.h" @@ -31,6 +30,74 @@ namespace { +/** + * @brief The role an emphasis level selects inside a base role's family. + * + * The theme authors all three levels, so emphasis is a lookup instead of a + * color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an + * explicit request and keeps that role at every emphasis; only the base roles + * take part in the mapping. Accent roles have no neutral middle member, so they + * are always used as named. + */ +AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis) +{ + switch (role) { + case AppColor::Icon: + return emphasis == IconEmphasis::Primary ? AppColor::IconStrong + : emphasis == IconEmphasis::Quiet ? AppColor::IconSoft + : AppColor::Icon; + case AppColor::Positive: + return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong + : emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft + : AppColor::Positive; + case AppColor::Negative: + return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong + : emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft + : AppColor::Negative; + default: + return role; + } +} + +/** + * @brief The scheme the active application palette is painting for. + * + * The fill needs the surface scheme to choose the badge glyph ink, and it cannot + * read it from the tone: an authored quiet level is a dark color on the dark + * scheme too. The theme's resolved scheme is the same source the palette file + * and the default app colors are chosen from, so the fill follows it instead of + * re-deriving the scheme from the live Window lightness, which can disagree + * when a theme pins a scheme the platform palette does not match. + */ +ThemeIconFill::Scheme activeScheme() +{ + if (themeManager != nullptr) { + return themeManager->isDarkModeActive() ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; + } + + // No manager (test binaries link the icon engine without the app): fall + // back to the live palette, which is the only scheme signal left. + const QPalette &palette = qApp->palette(); + return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light; +} + +/** + * @brief The ink a disabled glyph is painted with. + * + * A disabled icon used to be the palette's disabled text, a fixed gray whose + * contrast swings with the surface: on the darkest themes it out-reads the quiet + * level, so an enabled quiet glyph looks more disabled than a disabled one. + * Deriving it from the theme's own quiet ink keeps the order structural. + */ +QColor disabledGlyphInk(const QPalette &palette) +{ + const QColor quiet = themeManager->appColor(AppColor::IconSoft); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), quiet); + return ThemeIconInk::disabledInk(quiet, surface); +} + /** * @brief Reads an SVG through the `theme:` search path. */ @@ -265,7 +332,7 @@ private: QByteArray variant; if (badge) { variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' + - ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1(); + ThemeIconBadge::glyphColor(tone, activeScheme()).name(QColor::HexRgb).toLatin1(); } else if (sticker) { variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1(); } else if (resolvedHasSentinel) { @@ -283,7 +350,7 @@ private: if (badge) { // Geometry is computed from the device-pixel size, and the ratio is // attached below like every other path. - pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize); + pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize, activeScheme()); } else if (sticker) { pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize); } else if (resolvedHasSentinel) { @@ -318,6 +385,7 @@ private: mutable bool hasResolvedAsset = false; mutable bool hasIntrinsicSize = false; }; + } // namespace IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis) @@ -342,21 +410,18 @@ IconTint IconTint::quiet() QColor IconTint::resolve(QIcon::Mode mode) const { const QPalette &palette = qApp->palette(); - const QColor surface = palette.color(QPalette::Active, QPalette::Window); const QColor accent = palette.color(QPalette::Active, QPalette::Highlight); if (source == Source::AppColor) { // A disabled control must not keep signalling its semantic color. if (mode == QIcon::Disabled) { - return palette.color(QPalette::Disabled, QPalette::WindowText); + return disabledGlyphInk(palette); } - // The three treatments paint the same tone: a line glyph, a badge disc - // and a sticker fill must not disagree on how saturated the role is. - // The fill treatments apply this floor themselves, so this only changes - // the line path, and only for the desaturated dark-scheme tones. The - // emphasis then decides how loud that tone is, without changing its hue. - const QColor color = - ThemeIconInk::forEmphasis(ThemeIconFill::saturated(themeManager->appColor(appRole)), surface, emphasis); + // The theme authors one color per emphasis level, so the paint path + // uses the resolved role verbatim. The filled treatments only shape the + // tone's value to give a disc or a fill depth; hue and saturation stay + // the author's. + const QColor color = themeManager->appColor(emphasisRole(appRole, emphasis)); if (mode != QIcon::Selected) { return color; } @@ -372,17 +437,14 @@ QColor IconTint::resolve(QIcon::Mode mode) const const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; const QPalette::ColorRole effective = mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; - const QColor color = palette.color(group, effective); - // A neutral label role is inked as an icon, not as body text: it borrows a - // little accent chroma and is held to a contrast floor. Semantic roles and - // the selected-row swap keep the color the palette gave them. - if (group == QPalette::Active && (effective == QPalette::WindowText || effective == QPalette::Text)) { - // The primary action of a surface takes the accent itself; the other - // levels stay neutral. Emphasis then sets the contrast the glyph pays. - const QColor base = emphasis == IconEmphasis::Primary ? accent : ThemeIconInk::glyphInk(color, surface, accent); - return ThemeIconInk::forEmphasis(base, surface, emphasis); + // A neutral label role resolves to the theme's authored icon ink. A disabled + // one uses the dimmed quiet ink so it always sits below the quiet level; + // semantic roles keep the color the palette gave them. + if (effective == QPalette::WindowText || effective == QPalette::Text) { + return group == QPalette::Disabled ? disabledGlyphInk(palette) + : themeManager->appColor(emphasisRole(AppColor::Icon, emphasis)); } - return color; + return palette.color(group, effective); } QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style) @@ -407,7 +469,7 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const if (canvas.isEmpty()) { canvas = QSvgRenderer(svg).defaultSize(); } - return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas) + return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas, activeScheme()) : ThemeIconSticker::renderSticker(svg, tone, canvas); } diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index 231221c93..82500292a 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -47,18 +47,20 @@ enum class IconStyle }; /** - * @brief Selects how much attention a glyph claims inside its surface. + * @brief Selects which authored level of a color family a glyph uses. * - * Tint says which hue, style says how much shape, emphasis says how loud. The - * three are independent: a primary action can be a line glyph in the theme - * accent, and a quiet one can be a sticker in a dimmed tone. + * Tint says which family, style says how much shape, emphasis says how loud. + * The three are independent: a primary action can be a line glyph in the + * theme's primary ink, and a quiet one can be a sticker in that family's quiet + * level. * - * Secondary is the default and the color every icon had before the axis - * existed, so existing call sites keep their look. Primary is for the one - * action a surface exists for: a neutral primary glyph takes the accent, and a - * tinted one is pushed to full contrast. Quiet is for glyphs that label rather - * than act, such as the magnifier inside a search field: legible, but never - * competing with the buttons next to it. + * Each family (neutral icon, positive, negative) stores one color per level, so + * emphasis is a lookup rather than a computation: the palette editor's rows are + * the whole source of truth. Secondary is the default and the color every icon + * had before the axis existed, so existing call sites keep their look. Primary + * is for the one action a surface exists for. Quiet is for glyphs that label + * rather than act, such as the magnifier inside a search field: legible, but + * never competing with the buttons next to it. */ enum class IconEmphasis { @@ -82,7 +84,7 @@ public: IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers - /** @brief A neutral glyph in the accent tone, for the surface's primary action. */ + /** @brief A neutral glyph at the family's primary level, for the surface's primary action. */ static IconTint primary(); /** @brief A dimmed neutral glyph, for a control that labels rather than acts. */ @@ -91,21 +93,26 @@ public: /** * @brief Resolves the concrete color for an icon mode. * - * A disabled application color resolves to the disabled foreground, so an - * unavailable control does not keep its semantic color. A selected palette - * color keeps the WindowText -> HighlightedText swap. Application colors go - * through the saturation floor shared by the filled treatments, so a line - * glyph, a badge disc and a sticker fill carry the same tone. + * A disabled application color resolves to the derived disabled ink, the + * quiet level receded toward the surface, so an unavailable control does not + * keep its semantic color. A selected palette color keeps the + * WindowText -> HighlightedText swap. + * + * Application colors are returned verbatim: the theme author stores one + * value per emphasis level, so the paint path does not rewrite it. The + * filled treatments only shape that tone's value to give a shape depth; the + * hue and saturation stay the author's. * * Neutral label roles (WindowText, Text) resolve to icon ink rather than the - * raw palette color: ThemeIconInk::glyphInk borrows a little accent chroma - * and holds the glyph to a contrast floor, because a 2px glyph in body-text - * ink reads thinner than the text beside it. Semantic roles keep the hue the - * call site asked for. + * raw palette color: an unauthored neutral family derives it from the live + * WindowText and holds the glyph to a contrast floor, because a 2px glyph in + * body-text ink reads thinner than the text beside it. A theme that authors + * the neutral icon roles overrides that derivation. Semantic roles keep the + * hue the call site asked for. * - * Emphasis is applied after the hue: Primary lifts a neutral glyph to the - * accent and pushes a tinted one to full contrast; Quiet blends either - * toward the surface down to the non-text legibility floor. + * Emphasis selects the family's authored variant, so the palette editor's + * rows are the whole source of truth. A caller that names a variant + * explicitly (a strong or soft role) pins that level at every emphasis. */ QColor resolve(QIcon::Mode mode) const; diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp index 086d2be43..d5b07b3c6 100644 --- a/cockatrice/src/interface/theme_icon_badge.cpp +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -46,16 +46,16 @@ DiscFill discFill(const QColor &tone) namespace ThemeIconBadge { -QColor glyphColor(const QColor &tone) +QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme) { // The badge carries the ink the pre-sticker badge did: white on the light // scheme, and the tone darkened to an outline-dark ink on the dark scheme. - // The shared saturated disc is light enough there that white sits near - // 2.7:1 at the top, so the dark ink is the deliberate choice rather than a - // contrast measurement that a palette tweak could flip. Deriving it from - // the tone keeps themes in control. - return ThemeIconFill::isDarkSchemeTone(tone) ? ThemeIconFill::saturated(tone).darker(GLYPH_DARKER) - : QColor(Qt::white); + // The lifted disc is light enough there that white sits near 2.7:1 at the + // top, so the dark ink is the deliberate choice rather than a contrast + // measurement that a palette tweak could flip. Deriving it from the tone + // keeps themes in control, and the scheme is explicit because an authored + // quiet level is a dark tone on the dark scheme too. + return scheme == ThemeIconFill::Scheme::Dark ? tone.darker(GLYPH_DARKER) : QColor(Qt::white); } QSize glyphSize(const QSize &pixelSize) @@ -70,7 +70,7 @@ QSize glyphSize(const QSize &pixelSize) return QSize(glyphSide, glyphSide); } -QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme) { if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) { return {}; @@ -100,12 +100,12 @@ QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixe // The ring closes the disc: its outer edge touches the canvas, its inner // edge is the disc boundary, so the two meet without a seam. painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth)); + painter.setPen(QPen(tone.darker(RING_DARKER), ringWidth)); const qreal ringRadius = (side - ringWidth) / 2.0; painter.drawEllipse(discRect.center(), ringRadius, ringRadius); const QByteArray glyphSvg = resolveSvgStrokeWidth( - resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); + resolveSvgColorSentinel(svg, glyphColor(tone, scheme).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); QSvgRenderer renderer(glyphSvg); if (renderer.isValid()) { const QSize size = glyphSize(pixelSize); diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h index 412082377..f1c392d02 100644 --- a/cockatrice/src/interface/theme_icon_badge.h +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -6,6 +6,8 @@ #ifndef THEME_ICON_BADGE_H #define THEME_ICON_BADGE_H +#include "theme_icon_fill.h" + #include #include #include @@ -34,13 +36,17 @@ namespace ThemeIconBadge /** * @brief Glyph color for a badge disc. * - * White on the light scheme, the saturated tone darkened to an outline-dark ink + * White on the light scheme, the authored tone darkened to an outline-dark ink * on the dark scheme. The dark-scheme tones fill the disc light enough that * white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink - * the badge had before the filled treatments shared a fill. The dark ink is - * derived from the tone, so the theme keeps control of both ends. + * the badge had before the filled treatments shared a fill. The scheme is passed + * in rather than inferred from the tone, because an authored quiet level is a + * dark color on the dark scheme as well. + * + * @param tone Disc color, already resolved for the icon mode. + * @param scheme Scheme the badge is painted for. */ -QColor glyphColor(const QColor &tone); +QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme); /** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */ QSize glyphSize(const QSize &pixelSize); @@ -63,9 +69,10 @@ QSize glyphSize(const QSize &pixelSize); * @param tone Disc color, already resolved for the icon mode. * @param pixelSize Canvas size in device pixels; a non-square canvas centers * the badge and leaves the remaining area transparent. + * @param scheme Scheme the badge is painted for; selects the glyph ink. * @return The composited badge, or a null pixmap for empty or invalid input. */ -QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize); +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme); } // namespace ThemeIconBadge #endif // THEME_ICON_BADGE_H diff --git a/cockatrice/src/interface/theme_icon_fill.cpp b/cockatrice/src/interface/theme_icon_fill.cpp index 7615fb4fa..79690acea 100644 --- a/cockatrice/src/interface/theme_icon_fill.cpp +++ b/cockatrice/src/interface/theme_icon_fill.cpp @@ -1,61 +1,22 @@ #include "theme_icon_fill.h" -#include - namespace { -// Semantic tones are tuned for text. The dark-scheme ones are deliberately -// desaturated, which reads washed out when the same color fills a whole shape, -// so the fill gives its tone a saturation floor. -constexpr int MIN_SATURATION = 170; -// A tone this light belongs to the dark scheme; filling a glyph with it at full -// value is what looks pale, so its gradient is pulled down instead of up. -constexpr int LIGHT_TONE_VALUE = 200; -constexpr int MIN_VALUE = 200; +// The bottom end of every filled treatment. The authored tone is the top, so a +// single darkening keeps the emphasis level the palette author picked and still +// gives the shape the lit-from-above depth the pre-#7595 art had. constexpr int GRADIENT_BOTTOM_DARKER = 140; -constexpr int DARK_SCHEME_TOP_DARKER = 120; -constexpr int DARK_SCHEME_BOTTOM_DARKER = 170; -} // namespace - -namespace -{ -/** @brief Raises a tone's saturation, and optionally its value, for a filled shape. */ -QColor boost(const QColor &color, int minValue) -{ - if (color.hue() < 0 || (color.hsvSaturation() >= MIN_SATURATION && color.value() >= minValue)) { - return color; - } - QColor result = color; - result.setHsv(color.hue(), qMax(color.hsvSaturation(), MIN_SATURATION), qMax(color.value(), minValue)); - // Return RGB so callers comparing or caching the tone do not see a spec - // change for an unchanged color. - return result.toRgb(); -} } // namespace namespace ThemeIconFill { -QColor saturated(const QColor &tone) -{ - return boost(tone, 0); -} - -bool isDarkSchemeTone(const QColor &tone) -{ - return tone.value() >= LIGHT_TONE_VALUE; -} - QColor topColor(const QColor &tone) { - if (isDarkSchemeTone(tone)) { - return boost(tone, 0).darker(DARK_SCHEME_TOP_DARKER); - } - return boost(tone, MIN_VALUE); + return tone; } QColor bottomColor(const QColor &tone) { - const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER; - return boost(tone, 0).darker(darker); + return tone.darker(GRADIENT_BOTTOM_DARKER); } } // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_fill.h b/cockatrice/src/interface/theme_icon_fill.h index 4efcc136e..c0e8ee6eb 100644 --- a/cockatrice/src/interface/theme_icon_fill.h +++ b/cockatrice/src/interface/theme_icon_fill.h @@ -12,50 +12,41 @@ * @brief Picks the two colors of a filled treatment's vertical gradient. * * The pre-#7595 icons were filled with a lighter top over a darker bottom. The - * semantic tones are tuned for text, not for filling a whole shape; the - * dark-scheme tones in particular are desaturated and read washed out once a - * disc or a glyph carries them. The badge and the sticker share one fill rule so - * they sit next to each other with the same green or red. + * badge and the sticker share one fill rule so they sit next to each other with + * the same green or red. * - * The rule needs no scheme switch: a tone whose value is already high is a - * dark-scheme tone, so its gradient is pulled down. Filling a shape with a tone - * that light is what reads pale. + * The authored tone is the top of that gradient: the palette author stores one + * color per emphasis level, so the treatment only adds depth by darkening the + * bottom. Hue, saturation and value all stay the author's, so the rendered + * ladder is the authored ladder on every scheme. */ namespace ThemeIconFill { /** - * @brief Returns the tone with a saturation floor, for a filled shape. + * @brief Which scheme a treatment is painted for. * - * The hue and value are left alone, so a light-scheme tone stays as dark as the - * line glyph next to it, and an achromatic tone stays gray. - * - * @param tone Glyph color the treatment is rendered with. - * @return The tone, saturated enough to carry a whole shape. + * The scheme is a property of the surface, not of the tone: an authored quiet + * level is deliberately darker than its secondary level on the dark scheme too, + * so it cannot be inferred from the tone's value. Callers know the surface + * palette and pass it explicitly. */ -QColor saturated(const QColor &tone); - -/** - * @brief True when @p tone is a light tone, as the dark scheme's palette is. - * - * The fill branches on this: a tone this light is darkened for a filled shape, - * and the badge keeps its darkened ink on it instead of the light scheme's - * white glyph. - * - * @param tone Glyph color the treatment is rendered with. - */ -bool isDarkSchemeTone(const QColor &tone); +enum class Scheme +{ + Light, + Dark, +}; /** * @brief Returns the top color of the fill gradient. * @param tone Glyph color the treatment is rendered with. - * @return The lighter end of the gradient. + * @return The authored tone, unchanged. */ QColor topColor(const QColor &tone); /** * @brief Returns the bottom color of the fill gradient. * @param tone Glyph color the treatment is rendered with. - * @return The darker end of the gradient. + * @return The authored tone, darkened for depth. */ QColor bottomColor(const QColor &tone); } // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp index 1b0748091..7ed458f70 100644 --- a/cockatrice/src/interface/theme_icon_ink.cpp +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -12,20 +12,40 @@ namespace // accent fill. Every shipped theme accents in blue or violet, so the borrowed hue // stays neutral chrome instead of implying a state. constexpr double ACCENT_MIX = 0.70; +// The Positive family owns green and the Negative family owns red. An accent in +// either band is a semantic color the theme did not mean as chrome, so the +// neutral glyph keeps the accent's intensity but not its hue. +constexpr int SEMANTIC_GREEN_START = 80; +constexpr int SEMANTIC_GREEN_END = 165; +constexpr int SEMANTIC_RED_START = 340; +constexpr int SEMANTIC_RED_END = 25; +// Slate blue: the chrome hue every shipped theme already leans toward, and the +// least likely to be mistaken for a state. +constexpr int NEUTRAL_CHROME_HUE = 210; // An icon that labels a control is carrying text; see ui-assets.md. constexpr double LABEL_CONTRAST_FLOOR = 4.5; -// A primary glyph has to out-read the default one on both schemes. The accent is -// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a -// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast -// floor shared with the default" keeps the primary above the secondary. A 7:1 -// target does: it darkens the accent on light schemes and lightens it on dark -// ones, so the primary level always out-contrasts the default level. -constexpr double PRIMARY_CONTRAST = 7.0; -// A quiet glyph is blended this far into its surface. It has to stay findable -// without competing with the actions beside it. -constexpr double QUIET_BLEND = 0.55; -// WCAG 2.2 SC 1.4.11, the floor for a non-text element that carries meaning. -constexpr double QUIET_CONTRAST_FLOOR = 3.0; +// A primary glyph is the most chromatic member of its family. Contrast cannot +// order the ladder: on a dark surface the highest-contrast tone is the palest +// one, so a contrast target makes the primary look like a washed-out secondary +// and the ladder reads inverted. Primary instead raises the saturation by this +// many HSV points over the secondary and then solves the value back to the text +// floor, so it is louder in color at the same legibility. +constexpr int PRIMARY_SATURATION_STEP = 64; +// Quiet recedes by carrying less of the family's chroma, not by dropping closer +// to the surface: the disabled level owns the low-contrast end. It keeps this +// share of the secondary level's saturation. +constexpr int QUIET_SATURATION_PERCENT = 45; +// The quiet level shares the text floor with the other two levels. +constexpr double QUIET_CONTRAST_FLOOR = LABEL_CONTRAST_FLOOR; +// A disabled glyph recedes past the quiet level, so it is blended this much +// further into the surface... +constexpr double DISABLED_BLEND = 0.55; +// ...and then held to a floor below the quiet one so it stays findable. +constexpr double DISABLED_CONTRAST_FLOOR = 2.5; +// 8-bit quantization can drop a solved color just under the ratio it was solved +// for, so every search aims this much above the floor. Without it a palette +// value generated at exactly 4.5:1 measures 4.45:1 after the round trip. +constexpr double CONTRAST_ROUNDING_MARGIN = 0.10; // Binary-search depth for reachContrast(). 24 halvings resolve to well under // one 8-bit channel step, so the result is stable across platforms. constexpr int CONTRAST_SEARCH_STEPS = 24; @@ -59,18 +79,18 @@ QColor blend(const QColor &color, const QColor &target, double amount) QColor reachContrast(const QColor &color, const QColor &background, double minRatio) { - if (contrastRatio(color, background) >= minRatio) { + const double target = minRatio + CONTRAST_ROUNDING_MARGIN; + if (contrastRatio(color, background) >= target) { return color; } - // Move away from the background: toward whichever pole actually reaches - // further. Black and white cross at a luminance of about 0.18, not 0.5, so - // the midpoint test would pick white on a surface where black is far more - // legible -- and could drag an ink that already cleared the floor to white. - // The hue is kept by interpolating, not by replacing. - const QColor black(Qt::black); - const QColor white(Qt::white); - const QColor pole = contrastRatio(black, background) >= contrastRatio(white, background) ? black : white; - if (contrastRatio(pole, background) < minRatio) { + // Move toward whichever pole the background leaves room for. A medium-light + // surface is lighter than mid-gray but still too light to reach a text floor + // with white, so "light background means move toward black" is the wrong + // test: compare what each pole can actually reach. + const double blackRatio = contrastRatio(QColor(Qt::black), background); + const double whiteRatio = contrastRatio(QColor(Qt::white), background); + const QColor pole = blackRatio >= whiteRatio ? QColor(Qt::black) : QColor(Qt::white); + if (qMax(blackRatio, whiteRatio) < target) { return pole; } @@ -90,9 +110,39 @@ QColor reachContrast(const QColor &color, const QColor &background, double minRa return blend(color, pole, high); } +bool isSemanticHue(int hue) +{ + if (hue < 0) { + return false; + } + return (hue >= SEMANTIC_GREEN_START && hue <= SEMANTIC_GREEN_END) || hue >= SEMANTIC_RED_START || + hue <= SEMANTIC_RED_END; +} + +int neutralChromeHue() +{ + return NEUTRAL_CHROME_HUE; +} + +QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink) +{ + const double inkLuminance = relativeLuminance(ink); + const auto distance = [inkLuminance](const QColor &surface) { + return std::abs(relativeLuminance(surface) - inkLuminance); + }; + return distance(first) <= distance(second) ? first : second; +} + QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix) { - return reachContrast(blend(text, accent, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); + // A green or red accent is a Positive or Negative color, so borrowing its + // hue would paint neutral chrome the same color as a semantic state. Keep + // the accent's intensity, replace its hue with the chrome hue. + QColor source = accent; + if (isSemanticHue(accent.hue())) { + source = QColor::fromHsv(NEUTRAL_CHROME_HUE, accent.hsvSaturation(), qMax(accent.value(), 128)); + } + return reachContrast(blend(text, source, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); } double defaultAccentMix() @@ -105,27 +155,141 @@ double labelContrastFloor() return LABEL_CONTRAST_FLOOR; } +namespace +{ +/** @brief Whether black reaches a higher contrast against @p surface than white. */ +bool darkerPoleWins(const QColor &surface) +{ + return ThemeIconInk::contrastRatio(QColor(Qt::black), surface) >= + ThemeIconInk::contrastRatio(QColor(Qt::white), surface); +} + +/** @brief The luminance a color needs to reach @p ratio against @p surface. */ +double targetLuminance(const QColor &surface, double ratio) +{ + const double surfaceLuminance = ThemeIconInk::relativeLuminance(surface); + return darkerPoleWins(surface) ? (surfaceLuminance + 0.05) / ratio - 0.05 + : (surfaceLuminance + 0.05) * ratio - 0.05; +} + +/** + * @brief The value that puts @p hue at @p saturation on @p surface's floor. + * + * Luminance is monotone in HSV value at a fixed hue and saturation, so a + * bisection finds the boundary: the closest tone to the surface that still + * clears the floor, on either scheme. The direction depends on the surface: + * toward black (a light surface) the floor is a luminance ceiling, so the + * search keeps the highest passing value; toward white (a dark surface) it is a + * floor, so the search keeps the lowest passing value. Returns an invalid color + * when the tone cannot reach the floor at that saturation at all. + */ +QColor solveValue(int hue, int saturation, const QColor &surface, double floor) +{ + const bool towardBlack = darkerPoleWins(surface); + const double target = targetLuminance(surface, floor + CONTRAST_ROUNDING_MARGIN); + // Toward black a tone is legible at or below the target luminance, toward + // white at or above it. Testing the wrong side is what let a saturated + // violet at full value look "too dark" on a light surface and fall back to + // the unchanged secondary. + const auto passes = [hue, saturation, target, towardBlack](int value) { + const double luminance = ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, value)); + return towardBlack ? luminance <= target : luminance >= target; + }; + + if (passes(255)) { + // Toward black, full value is the palest tone: if even that clears the + // floor, the whole ramp does and the surface wants the authored tone. + // Toward white it is the palest tone but also the most legible one, so + // the search still starts from the failing end. + if (towardBlack) { + return QColor::fromHsv(hue, saturation, 255).toRgb(); + } + } else if (!towardBlack) { + // Toward white the floor needs the palest tone; no darker value can + // reach it at this saturation. + return {}; + } + + int passing = towardBlack ? 0 : 255; + int failing = towardBlack ? 255 : 0; + for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { + const int middle = (passing + failing) / 2; + if (passes(middle)) { + passing = middle; + } else { + failing = middle; + } + } + return QColor::fromHsv(hue, saturation, passing).toRgb(); +} + +/** + * @brief Raises a color's saturation, then solves its value for the floor. + * + * Saturation is raised before the value is solved, because reaching a contrast + * floor by adding white is what desaturates a tone in the first place. If the + * hue cannot reach the floor at the raised saturation inside the gamut, the step + * shrinks until it can, so an already-vivid tone keeps what it has. A gray has + * no saturation to raise and falls back to the contrast search. + */ +QColor chromaticStep(const QColor &color, const QColor &surface, double floor, int stepPoints) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const int hue = color.hue(); + const int secondary = color.hsvSaturation(); + for (int saturation = qMin(255, secondary + stepPoints); saturation > secondary; --saturation) { + const QColor solved = solveValue(hue, saturation, surface, floor); + if (solved.isValid()) { + return solved; + } + } + return ThemeIconInk::reachContrast(color, surface, floor); +} + +/** + * @brief Mutes a color's saturation, then solves its value for the floor. + * + * The mirror of chromaticStep: quiet stays on the same floor as the other + * levels and recedes by carrying less of the family's chroma, so it cannot drop + * below the disabled level's contrast by construction. A gray has no saturation + * to mute and falls back to the contrast search. + */ +QColor mutedStep(const QColor &color, const QColor &surface, double floor, int percent) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const int saturation = qBound(0, qRound(color.hsvSaturation() * percent / 100.0), 255); + const QColor solved = solveValue(color.hue(), saturation, surface, floor); + return solved.isValid() ? solved : ThemeIconInk::reachContrast(color, surface, floor); +} +} // namespace + QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis) { switch (emphasis) { case IconEmphasis::Primary: - return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb(); + return chromaticStep(color, surface, LABEL_CONTRAST_FLOOR, PRIMARY_SATURATION_STEP).toRgb(); case IconEmphasis::Quiet: - return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb(); + return mutedStep(color, surface, QUIET_CONTRAST_FLOOR, QUIET_SATURATION_PERCENT).toRgb(); case IconEmphasis::Secondary: break; } return color; } -double primaryContrastFloor() +QColor disabledInk(const QColor &quiet, const QColor &surface) { - return PRIMARY_CONTRAST; + return reachContrast(blend(quiet, surface, DISABLED_BLEND), surface, DISABLED_CONTRAST_FLOOR).toRgb(); } -double quietBlend() +double primaryContrastFloor() { - return QUIET_BLEND; + return LABEL_CONTRAST_FLOOR; } double quietContrastFloor() diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h index 6ba90a239..dfa585722 100644 --- a/cockatrice/src/interface/theme_icon_ink.h +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -24,7 +24,9 @@ * on. The chroma is what makes it read as an icon rather than as leftover text; * the floor keeps a theme with a weak text color from producing a glyph that is * not legible. Neither step invents a color: both are derived from the palette - * the theme already ships. + * the theme already ships. An unauthored neutral family stops at the floor: the + * live platform palette is the only source that knows the actual scheme, so a + * chroma pinned in a palette file could belong to the other one. * * The rule only applies to the neutral label roles. A semantic tint * (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the @@ -32,6 +34,27 @@ */ namespace ThemeIconInk { +/** + * @brief Whether @p hue belongs to the band the Positive or Negative family owns. + * + * A green accent is a Positive color and a red accent is a Negative one, whether + * or not the theme means it that way. Neutral chrome must not borrow either hue, + * or "settings" and "add" are painted the same color. + */ +bool isSemanticHue(int hue); + +/** @brief The hue neutral chrome falls back to when the accent is semantic. */ +int neutralChromeHue(); + +/** + * @brief The harder of two surfaces to keep an ink legible on. + * + * Contrast is monotone in the luminance distance between an ink and its + * surface, so the surface nearest @p ink is the one it reads worst on. Choosing + * that one keeps a single authored ink above the floor on both. + */ +QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink); + /** * @brief WCAG relative luminance of an opaque color. */ @@ -54,9 +77,10 @@ QColor blend(const QColor &color, const QColor &target, double amount); /** * @brief Pushes @p color away from @p background until it reaches @p minRatio. * - * Moves @p color toward black on a light background and toward white on a dark - * one, so the hue survives the adjustment. Returns @p color unchanged when it - * already meets the floor, which is the common case for a shipped palette. + * Moves @p color toward whichever pole, black or white, actually reaches + * @p minRatio against @p background, so the hue survives the adjustment. + * Returns @p color unchanged when it already meets the floor, which is the + * common case for a shipped palette. * * @param color Color to adjust. * @param background Surface the color is drawn on. @@ -82,7 +106,9 @@ double defaultAccentMix(); * @param accent The theme accent, normally `QPalette::Highlight`. * @param accentMix How much of the accent color to borrow. Defaults to * defaultAccentMix(); previews and tests sweep it to compare - * strengths, production callers do not pass it. + * strengths. Production passes 0 for an unauthored neutral + * family, where the live platform palette is the only source + * that knows the actual scheme. * @return The ink to resolve `currentColor` to, as an RGB color. */ QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix = defaultAccentMix()); @@ -99,13 +125,12 @@ double labelContrastFloor(); /** * @brief Applies an emphasis level to an already-hued glyph color. * - * Secondary keeps the hue untouched, so it is the no-op the default needs. - * Primary pushes the color to a higher floor than the default, because the - * accent is a mid-tone: on a light scheme it is lighter than the neutral ink and - * on a dark one darker, so only a stronger contrast target keeps the primary - * level above the secondary on both. Quiet - * blends the color toward the surface it sits on and stops at the non-text - * legibility floor, so a labelling glyph recedes without disappearing. + * The three levels share one legibility floor and differ by chroma, because + * contrast cannot order them: on a dark surface the highest-contrast tone is the + * palest one, so a contrast target inverts the ladder. Primary is the most + * chromatic member of its family, secondary keeps the color untouched (the + * no-op the default needs), and quiet mutes the color to a fraction of its + * saturation before solving it back to the same floor. * * @param color Hue-resolved glyph color. * @param surface Surface the glyph is drawn on. @@ -114,13 +139,27 @@ double labelContrastFloor(); */ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis); -/** @brief Contrast floor the primary level reaches, so it out-reads the default. */ +/** + * @brief The ink a disabled glyph is painted with, given the quiet level. + * + * A disabled glyph is the quiet ink receded further toward the surface and held + * to a floor below the quiet one. The palette's own disabled text is a fixed + * gray whose contrast varies with the surface, so on the darkest themes it + * out-reads the quiet level and an enabled glyph looks more disabled than a + * disabled one. Deriving the disabled ink from the quiet ink makes that order + * structural instead of incidental. + */ +QColor disabledInk(const QColor &quiet, const QColor &surface); + +/** + * @brief Contrast floor the primary level reaches, so it stays legible. + * + * Primary is ordered by chroma, not contrast, so it shares the text floor + * rather than clearing a higher one. + */ double primaryContrastFloor(); -/** @brief How far a quiet glyph is blended into its surface. */ -double quietBlend(); - -/** @brief Contrast floor a quiet glyph keeps, the WCAG non-text minimum. */ +/** @brief Contrast floor the quiet level reaches, shared with the other levels. */ double quietContrastFloor(); } // namespace ThemeIconInk diff --git a/cockatrice/src/interface/theme_icon_sticker.cpp b/cockatrice/src/interface/theme_icon_sticker.cpp index c5f86c7ac..135ba71b8 100644 --- a/cockatrice/src/interface/theme_icon_sticker.cpp +++ b/cockatrice/src/interface/theme_icon_sticker.cpp @@ -30,7 +30,7 @@ namespace ThemeIconSticker { QColor outlineColor(const QColor &tone) { - return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER); + return tone.darker(OUTLINE_DARKER); } QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 1cf62ae8b..1ad0309b2 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "pixel_map_generator.h" #include "theme_config.h" +#include "theme_icon_ink.h" #include #include @@ -651,8 +652,37 @@ QColor ThemeManager::appColor(AppColor::Role role) const qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); } + case AppColor::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: { + // Legacy fallback for a theme written before the icon roles + // existed, or one that deliberately leaves the neutral family + // unauthored (System). No accent chroma is borrowed here: the + // palette file that would carry it cannot know the platform scheme, + // so neutral ink is the live WindowText held to the label floor. + // The three levels are the ink rule this series shipped, applied + // once here so authored roles never get recomputed at paint time. + const QPalette &palette = qApp->palette(); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), text); + const QColor ink = ThemeIconInk::glyphInk(text, surface, accent, 0.0); + switch (role) { + case AppColor::IconStrong: + return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary); + case AppColor::IconSoft: + return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Quiet); + case AppColor::Icon: + default: + return ink; + } + } case AppColor::Positive: + case AppColor::PositiveStrong: + case AppColor::PositiveSoft: case AppColor::Negative: + case AppColor::NegativeStrong: + case AppColor::NegativeSoft: // Semantic colors are independent of the accent: a missing role must // not silently paint "remove" in the theme's highlight color. return defaultAppColor(role, isDarkModeActive()); diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index 5a961bd5d..419859844 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #7ca0e5 +IconStrong = #6a9dff +IconSoft = #93a1ba +Positive = #5bb174 +PositiveStrong = #30b758 +PositiveSoft = #84a98f +Negative = #ed8077 +NegativeStrong = #ff7367 +NegativeSoft = #be9794 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index a958b39d1..95973457f 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #2f58ac +IconStrong = #054ddf +IconSoft = #505c76 +Positive = #166931 +PositiveStrong = #006b22 +PositiveSoft = #40634b +Negative = #b0251d +NegativeStrong = #ba0a00 +NegativeSoft = #7c504d diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index 8be52dc5f..adc992a89 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -69,7 +69,14 @@ Accent = #ff1e1e1e [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 -Positive = #6dd58c -Negative = #ff8a80 +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #74ace4 +IconStrong = #58acff +IconSoft = #95aabf +Positive = #5fb97a +PositiveStrong = #32bf5c +PositiveSoft = #8ab196 +Negative = #f8867c +NegativeStrong = #ff8075 +NegativeSoft = #c79e9b diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 9548af916..5e2caa4b8 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -69,7 +69,14 @@ Accent = #fff0f0f0 [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 -Positive = #1b7f3b -Negative = #b3261e +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #196dc2 +IconStrong = #006ad4 +IconSoft = #546f8a +Positive = #1b7d3a +PositiveStrong = #007e28 +PositiveSoft = #4c765a +Negative = #cf2c23 +NegativeStrong = #da0c00 +NegativeSoft = #935f5c diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index 26bee5be3..6782bb7c4 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #bd8d4a +IconStrong = #d0851e +IconSoft = #a79479 +Positive = #55a66d +PositiveStrong = #2dab52 +PositiveSoft = #7c9f86 +Negative = #df7970 +NegativeStrong = #ff5e50 +NegativeSoft = #b38e8b diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index 0219fce8e..e405881cd 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Positive = #1b7f3b -Negative = #b3261e - +Icon = #725025 +IconStrong = #7e4b09 +IconSoft = #625544 +Positive = #15642e +PositiveStrong = #006621 +PositiveSoft = #3d5f48 +Negative = #a7231c +NegativeStrong = #b10a00 +NegativeSoft = #764c4a diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index e013e3602..de36f449a 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #9380eb +IconStrong = #927aff +IconSoft = #928cb0 +Positive = #519f69 +PositiveStrong = #2ba44f +PositiveSoft = #779881 +Negative = #d5736b +NegativeStrong = #ff4f40 +NegativeSoft = #ab8885 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index 71d86917c..b93002e25 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #5338ce +IconStrong = #4118fe +IconSoft = #585078 +Positive = #15622e +PositiveStrong = #006420 +PositiveSoft = #3c5d47 +Negative = #a4231b +NegativeStrong = #ae0900 +NegativeSoft = #744b48 diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index 9f945fa30..b26bbd268 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -3,8 +3,17 @@ # overrides. Only the identity and semantic app colors are provided here, so # buttons keep their classic greens while the actual palette stays native to # the OS. +# The neutral icon family is deliberately not authored: the palette file is +# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer +# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a +# platform palette of the other scheme. Neutral ink derives from the live +# WindowText instead. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 -Positive = #ff6dd58c -Negative = #ffff8a80 +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff6dd58c +PositiveStrong = #ff32c05c +PositiveSoft = #ff8ab196 +Negative = #ffff8a80 +NegativeStrong = #ffff8378 +NegativeSoft = #ffc79e9b diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 32cf7845c..6d6d7ebf4 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -3,8 +3,17 @@ # overrides. Only the identity and semantic app colors are provided here, so # buttons keep their classic greens while the actual palette stays native to # the OS. +# The neutral icon family is deliberately not authored: the palette file is +# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer +# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a +# platform palette of the other scheme. Neutral ink derives from the live +# WindowText instead. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 -Positive = #ff1b7f3b -Negative = #ffb3261e +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff1a7d3a +PositiveStrong = #ff007e28 +PositiveSoft = #ff4c7659 +Negative = #ffb3261e +NegativeStrong = #ffda0b00 +NegativeSoft = #ff935f5c diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 56d3e2c4c..1fb24eb65 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #8493a2 +IconStrong = #6b95bf +IconSoft = #8c9399 +Positive = #52a16a +PositiveStrong = #2ba650 +PositiveSoft = #789a82 +Negative = #d8756c +NegativeStrong = #ff5344 +NegativeSoft = #ad8a86 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index 04461013b..1599d71fc 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #495a6e +IconStrong = #375a84 +IconSoft = #525961 +Positive = #16662f +PositiveStrong = #006721 +PositiveSoft = #3e6049 +Negative = #aa241c +NegativeStrong = #b40a00 +NegativeSoft = #784d4b diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp index 212987ec0..1d027a9e7 100644 --- a/tests/theme_migration/icon_treatment_preview_test.cpp +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -187,9 +187,10 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; for (const Family &family : families) { - const QColor raw = config.appColors.value(family.role, defaultAppColor(family.role, dark)); - ASSERT_TRUE(raw.isValid()) << theme.toStdString() << " " << family.name; - const QColor tone = ThemeIconFill::saturated(raw); + // The authored role is the tone the filled shape carries; the + // treatments apply their own fill rule from it. + const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name; for (int state = 0; state < 2; ++state) { const bool isDisabled = state == 1; @@ -221,7 +222,9 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) if (treatment == 1) { return ThemeIconSticker::renderSticker(svg, ink, pixelSize); } - return ThemeIconBadge::renderBadge(svg, ink, pixelSize); + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light); }); const qreal x = cellX + 6 + static_cast(glyphIndex) * SLOT + (SLOT - side) / 2.0; diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index f51282e0a..93203be6c 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include namespace { @@ -79,15 +80,33 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) /** * @brief The ink the engine resolves a neutral glyph to, for @p palette. * - * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink - * borrows accent chroma and holds the result to a contrast floor. Tests that - * pin the rendered color ask the rule instead of restating it. + * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink holds + * the platform text color to a contrast floor. Tests that pin the rendered color + * ask the rule instead of restating it. No accent chroma is borrowed: this is + * the fallback an unauthored neutral family takes, so it works whatever scheme + * the platform palette actually is. */ QColor expectedNeutralInk(const QPalette &palette) { - 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), 0.0); +} + +/** + * @brief Renders a glyph at the sheet's device pixel ratio. + * + * The preview sheets paint through a painter scaled by @p dpr, so a pixmap + * rendered at logical size would be upscaled on the way into the file and read + * as blurry. Rendering at device size and tagging the pixmap keeps one source + * pixel per device pixel. + */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; } /** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ @@ -274,17 +293,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. */ @@ -298,10 +329,16 @@ TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText) qApp->setPalette(palette); const IconTint tint(QPalette::WindowText); - // The normal ink is derived from the active text color, but it is icon ink - // rather than the text color itself. - EXPECT_NE(tint.resolve(QIcon::Normal), activeText); - EXPECT_EQ(tint.resolve(QIcon::Normal), expectedNeutralInk(qApp->palette())); + // The normal ink is the accent-free neutral fallback: the platform text + // color held to the label floor. A text color that already clears the floor + // is the ink verbatim, so this pins the rule and the floor, not a shift. + const QColor normal = tint.resolve(QIcon::Normal); + EXPECT_EQ(normal, expectedNeutralInk(qApp->palette())); + const QPalette resolved = qApp->palette(); + const QColor surface = ThemeIconInk::harderSurface(resolved.color(QPalette::Active, QPalette::Window), + resolved.color(QPalette::Active, QPalette::Button), + resolved.color(QPalette::Active, QPalette::WindowText)); + EXPECT_GE(ThemeIconInk::contrastRatio(normal, surface), ThemeIconInk::labelContrastFloor()); // A selected row paints on the highlight, so the ink is the palette's // highlighted text rather than the icon ink. EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText); @@ -332,28 +369,24 @@ TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint) } /** - * @brief Line glyphs share the filled treatments' saturation floor. + * @brief A line glyph renders the authored role verbatim. * - * The dark-scheme semantic tones are the visible case, but `AccentSoft` is below - * the floor in every shipped light palette, so the resolve path is exercised - * without swapping the theme. A line glyph next to a badge or a sticker has to - * carry the same tone; reverting the floor here would leave it pale beside them. + * The emphasis colors are authored data now, so the paint path must not + * rewrite them. A muted quiet role stays muted on a line glyph, and the badge + * and sticker only shape the value of that tone to give the shape depth. */ -TEST_F(ThemeIconRenderTest, LineTintAppliesTheFillSaturationFloor) +TEST_F(ThemeIconRenderTest, LineTintRendersTheAuthoredRoleVerbatim) { const QColor raw = themeManager->appColor(AppColor::AccentSoft); ASSERT_TRUE(raw.isValid()); ASSERT_LT(raw.hsvSaturation(), 170); - const QColor saturated = ThemeIconFill::saturated(raw); - EXPECT_NE(saturated, raw); - EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), saturated); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), raw); const QIcon icon = loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::AccentSoft); const QPixmap pixmap = icon.pixmap(QSize(24, 24)); ASSERT_FALSE(pixmap.isNull()); - EXPECT_GT(countOpaquePixels(pixmap, saturated), 0); - EXPECT_EQ(countOpaquePixels(pixmap, raw), 0); + EXPECT_GT(countOpaquePixels(pixmap, raw), 0); } /** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */ @@ -372,8 +405,8 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); EXPECT_GT(image.pixelColor(32, 32).alpha(), 200); - // The ring is the saturated tone darkened, and it must actually be painted. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + // The ring is the authored tone darkened, and it must actually be painted. + EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0); // The fill runs from a lighter top to a darker bottom. Sample left of the // check, which now reaches almost to the disc edge. @@ -385,40 +418,45 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) EXPECT_GT(top.lightness(), bottom.lightness()); // The glyph uses the contrast color chosen for this tone. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0); } /** - * @brief The dark-scheme disc carries the same saturated fill as the sticker. + * @brief The dark-scheme disc keeps the authored tone and darkens it. * - * The dark tones are tuned for text and read as pale mint or salmon once they - * fill a disc, so the badge saturates its fill. The dark scheme keeps the - * darkened ink the badge had before the fill moved to the sticker ramp. + * The authored tone is the top of the gradient, so the level the palette author + * picked survives the treatment; the shape only adds depth by darkening the + * bottom. It must not re-saturate the tone: the authored saturation is the theme + * author's choice. The dark scheme keeps the darkened ink the badge had before + * the fill moved to the sticker ramp. */ -TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill) +TEST_F(ThemeIconRenderTest, BadgeDarkSchemeKeepsTheAuthoredTone) { const QColor tone = defaultAppColor(AppColor::Positive, true); ASSERT_TRUE(tone.isValid()); ASSERT_GE(tone.value(), 200); - const QPixmap pixmap = ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64)); + const QPixmap pixmap = + ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64), ThemeIconFill::Scheme::Dark); ASSERT_FALSE(pixmap.isNull()); const QImage image = pixmap.toImage(); - // The disc neither keeps the pale raw tone nor the old unsaturating bottom. - EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); - EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(104)), 0); const QColor top = image.pixelColor(10, 16); const QColor bottom = image.pixelColor(10, 48); ASSERT_GT(top.alpha(), 200); ASSERT_GT(bottom.alpha(), 200); - // Antialiasing costs a point or two of saturation at the sample points. - EXPECT_GE(top.hsvSaturation(), 160); - EXPECT_GE(bottom.hsvSaturation(), 160); + // The top is a darkening of the authored tone, never a lift, and it keeps + // the authored saturation; the sample sits a little down the gradient. + EXPECT_LE(top.value(), tone.value()); + EXPECT_GT(top.value(), bottom.value()); + EXPECT_LT(bottom.value(), tone.value()); EXPECT_GT(top.lightness(), bottom.lightness()); + // Antialiasing costs a point or two of saturation at the sample points. + EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24); + EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24); // The dark scheme keeps the darkened ink, and it is actually painted. - const QColor ink = ThemeIconBadge::glyphColor(tone); + const QColor ink = ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Dark); ASSERT_NE(ink, QColor(Qt::white)); EXPECT_GT(countOpaquePixels(pixmap, ink), 0); } @@ -427,8 +465,8 @@ TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill) * @brief The badge ink follows the scheme. * * Light-scheme tones keep the white glyph of the old art; dark-scheme tones - * keep the darkened ink, because the shared saturated disc is light enough that - * white would sit near 2.7:1 at its top. Pinning both cases stops the glyph from + * keep the darkened ink, because the lifted disc is light enough that white + * would sit near 2.7:1 at its top. Pinning both cases stops the glyph from * silently flipping back to white when the fill or the palette changes. */ TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) @@ -445,7 +483,8 @@ TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) for (const auto &testCase : cases) { const QColor tone = defaultAppColor(testCase.role, testCase.dark); ASSERT_TRUE(tone.isValid()); - const QColor ink = ThemeIconBadge::glyphColor(tone); + const QColor ink = ThemeIconBadge::glyphColor(tone, testCase.dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light); if (testCase.dark) { EXPECT_NE(ink, QColor(Qt::white)); EXPECT_LT(ink.value(), 100); @@ -488,7 +527,7 @@ TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize) EXPECT_GT(image.pixelColor(7, 7).alpha(), 200); // A 1px ring on a 15px canvas antialiases into the disc and has no exact // color, so the glyph is what has to stay legible. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 8); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 8); } /** @brief The pixmap API's badge path agrees with the engine's. */ @@ -499,8 +538,8 @@ TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine) QSize(48, 48), IconStyle::Badge); ASSERT_FALSE(pixmap.isNull()); EXPECT_EQ(pixmap.size(), QSize(48, 48)); - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0); } /** @@ -577,16 +616,14 @@ TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath) // The authored color survives; no disc tone and no ring are painted. EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); } /** @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); @@ -595,10 +632,10 @@ 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, ThemeIconFill::saturated(tone).darker(300)), 0); - EXPECT_EQ(countOpaquePixels(pixmap, disabledText.darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.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); } /** @@ -632,13 +669,13 @@ TEST_F(ThemeIconRenderTest, StickerPaintsToneFillWithDarkOutline) } /** - * @brief The dark-scheme fill is the branch the treatment exists for. + * @brief The dark-scheme fill keeps the authored tone and darkens it. * - * That tone is light and deliberately desaturated, so a plain gradient paints a - * pale mint shape. The fill has to raise its saturation and pull its value down - * instead of up. + * The authored tone is the top of the gradient, so the level the palette author + * picked survives the treatment. The treatment only adds depth by darkening the + * bottom, without re-saturating the tone. */ -TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone) +TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillKeepsTheAuthoredTone) { const QColor tone = defaultAppColor(AppColor::Positive, true); ASSERT_TRUE(tone.isValid()); @@ -652,10 +689,14 @@ TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone const QColor bottom = image.pixelColor(32, 50); ASSERT_GT(top.alpha(), 200); ASSERT_GT(bottom.alpha(), 200); - EXPECT_LT(top.value(), tone.value()); + // The top is a darkening of the authored tone, never a lift, and it keeps + // the authored saturation; the sample sits a little down the gradient. + EXPECT_LE(top.value(), tone.value()); + EXPECT_GT(top.value(), bottom.value()); EXPECT_LT(bottom.value(), tone.value()); EXPECT_GT(top.lightness(), bottom.lightness()); - EXPECT_GE(top.hsvSaturation(), 170); + EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24); + EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24); } /** @brief The sticker is rendered at the device-pixel size, not upscaled later. */ @@ -726,10 +767,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); @@ -739,7 +778,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. */ @@ -750,7 +789,7 @@ TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone) loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64)); ASSERT_FALSE(pixmap.isNull()); - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); EXPECT_GT(countOpaquePixels(pixmap, tone), 0); } @@ -836,7 +875,8 @@ TEST(IconTintPreview, WritesPngsWhenRequested) painter.drawText(QPointF(MARGIN, rowTop + 16), QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr( + QSize(side, side), DPR, [&](const QSize &pixelSize) { return renderLine(svg, tone, pixelSize); }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; // Bottom-align the sizes and put the caption below the 48px row. painter.drawPixmap(QPointF(x, rowTop + 54 - side), pixmap); @@ -939,8 +979,8 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) const QByteArray svg = readSvg(glyph.name); ASSERT_FALSE(svg.isEmpty()) << glyph.name; // The preview must show the line glyph the engine paints, and the - // engine saturates AppColor tones before drawing them. - const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + // engine renders an authored tone verbatim. + const QColor tone = defaultAppColor(glyph.role, dark); for (int row = 0; row < 2; ++row) { const bool sticker = row == 1; @@ -950,8 +990,10 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = sticker ? ThemeIconSticker::renderSticker(svg, tone, QSize(side, side)) - : renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return sticker ? ThemeIconSticker::renderSticker(svg, tone, pixelSize) + : renderLine(svg, tone, pixelSize); + }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap); if (row == 1) { @@ -973,8 +1015,11 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; const QColor tone = defaultAppColor(badgeContext[i].role, dark); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; - painter.drawPixmap(QPointF(x, y + 8), - ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE))); + painter.drawPixmap( + QPointF(x, y + 8), renderAtDpr(QSize(CONTEXT_SIZE, CONTEXT_SIZE), DPR, [&](const QSize &pixelSize) { + return ThemeIconBadge::renderBadge( + svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); + })); painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name)); } @@ -985,18 +1030,20 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) for (std::size_t i = 0; i < std::size(badgeContext); ++i) { const QByteArray svg = readSvg(badgeContext[i].name); ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; - const QColor tone = ThemeIconFill::saturated(defaultAppColor(badgeContext[i].role, dark)); + const QColor tone = defaultAppColor(badgeContext[i].role, dark); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH * 1.5; for (int j = 0; j < 3; ++j) { const QSize side(CONTEXT_SIZE, CONTEXT_SIZE); - QPixmap pixmap; - if (j == 0) { - pixmap = renderLine(svg, tone, side); - } else if (j == 1) { - pixmap = ThemeIconBadge::renderBadge(svg, tone, side); - } else { - pixmap = ThemeIconSticker::renderSticker(svg, tone, side); - } + const QPixmap pixmap = renderAtDpr(side, DPR, [&](const QSize &pixelSize) { + if (j == 0) { + return renderLine(svg, tone, pixelSize); + } + if (j == 1) { + return ThemeIconBadge::renderBadge( + svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); + } + return ThemeIconSticker::renderSticker(svg, tone, pixelSize); + }); const qreal px = x + j * (CONTEXT_SIZE + 16); painter.drawPixmap(QPointF(px, y + 8), pixmap); painter.drawText(QPointF(px, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(FAMILY_LABELS[j])); @@ -1091,8 +1138,8 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) const QByteArray svg = readSvg(glyph.name); ASSERT_FALSE(svg.isEmpty()) << glyph.name; // The preview must show the line glyph the engine paints, and the - // engine saturates AppColor tones before drawing them. - const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + // engine renders an authored tone verbatim. + const QColor tone = defaultAppColor(glyph.role, dark); for (int row = 0; row < 2; ++row) { const bool badge = row == 1; @@ -1102,8 +1149,12 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side)) - : renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return badge ? ThemeIconBadge::renderBadge(svg, tone, pixelSize, + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light) + : renderLine(svg, tone, pixelSize); + }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; // Align all sizes on a common baseline so the weight // difference between 15px and 48px is visible. @@ -1128,9 +1179,11 @@ namespace struct InkPalette { QColor text; + QColor buttonText; QColor window; QColor button; QColor accent; + QMap roles; }; /** @@ -1146,12 +1199,37 @@ InkPalette shippedPalette(const QString &theme, bool dark) .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); const PaletteConfig config = PaletteConfig::fromFile(file); - if (!config.hasPalette()) { + // A theme may author only [AppColors] (System does), so appColors alone do + // not mean it ships a palette: the keys have to be present. The authored + // neutral-family assertions therefore cover only themes with a palette; + // System deliberately leaves its neutral family unauthored, which + // ShippedPalettesAuthorTheEmphasisLadder pins, and its semantic fallbacks + // are measured against representative platform surfaces by + // DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces. + if (config.colors.isEmpty()) { return {}; } const QPalette palette = config.apply(QPalette()); - return {palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::Window), - palette.color(QPalette::Active, QPalette::Button), palette.color(QPalette::Active, QPalette::Highlight)}; + return { + palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::ButtonText), + palette.color(QPalette::Active, QPalette::Window), palette.color(QPalette::Active, QPalette::Button), + palette.color(QPalette::Active, QPalette::Highlight), config.appColors}; +} + +/** + * @brief The [AppColors] a shipped theme authors, even when it ships no palette. + * + * System deliberately has no palette of its own, but it still authors its + * semantic roles. The neutral icon family stays unauthored there so it follows + * the live platform palette; see ShippedPalettesAuthorTheEmphasisLadder. + */ +QMap shippedAppColors(const QString &theme, bool dark) +{ + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + return PaletteConfig::fromFile(file).appColors; } } // namespace @@ -1171,6 +1249,67 @@ 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 Primary stays louder than secondary on a light surface. + * + * A saturated blue or violet at full value is already dark enough to clear the + * label floor on a light surface. When solveValue() read the target as a + * minimum in both directions it rejected that tone, walked the whole saturation + * ladder and fell back to the unchanged secondary, so primary was never + * distinguishable. #5a3ee2 on #efeff4 is the shipped Plasma accent's shape. + */ +TEST(IconInk, PrimaryRaisesAChromaticToneOnALightSurface) +{ + const QColor surface(QStringLiteral("#efeff4")); + const QColor violet(QStringLiteral("#5a3ee2")); + ASSERT_GE(ThemeIconInk::contrastRatio(violet, surface), ThemeIconInk::labelContrastFloor()) + << "the fixture must already be legible, so the fallback would be a no-op"; + + const QColor primary = ThemeIconInk::forEmphasis(violet, surface, IconEmphasis::Primary); + ASSERT_TRUE(primary.isValid()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GT(primary.hsvSaturation(), violet.hsvSaturation()) << "primary fell back to the unchanged secondary"; + EXPECT_NEAR(primary.hue(), violet.hue(), 2); +} + +/** + * @brief A green or red accent must not tint neutral chrome. + * + * 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 = @@ -1334,8 +1473,14 @@ TEST(IconInkPreview, WritesPngsWhenRequested) ASSERT_FALSE(svg.isEmpty()) << GLYPHS[i]; const qreal cellOffset = static_cast(i) * (side + CELL_GAP); const qreal glyphY = rowY + (ROW_HEIGHT - side) / 2.0; - painter.drawPixmap(QPointF(oldX + cellOffset, glyphY), renderLine(svg, oldInk, QSize(side, side))); - painter.drawPixmap(QPointF(newX + cellOffset, glyphY), renderLine(svg, newInk, QSize(side, side))); + painter.drawPixmap(QPointF(oldX + cellOffset, glyphY), + renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return renderLine(svg, oldInk, pixelSize); + })); + painter.drawPixmap(QPointF(newX + cellOffset, glyphY), + renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return renderLine(svg, newInk, pixelSize); + })); } rowY += ROW_HEIGHT; } @@ -1365,97 +1510,352 @@ bool applyShippedPalette(const QString &theme, bool dark) qApp->setPalette(config.apply(QPalette())); return true; } - -QColor surfaceColor() -{ - return qApp->palette().color(QPalette::Active, QPalette::Window); -} } // namespace /** - * @brief The primary action of a surface takes the accent, not a darker gray. + * @brief Emphasis selects the authored variant of a family, not a computed color. * - * A glyph hierarchy cannot be built out of "dark" and "darker": at any usable - * contrast a neutral ink is already near the palette's pole, so the top level - * has to be a different color. The accent is the palette's own answer to "which - * color means action", so primary uses it instead of inventing a token. + * The theme stores one color per level, so the mapping is a lookup: a base role + * follows the caller's emphasis, while naming a variant pins that level at every + * emphasis. This is the contract that makes the palette editor's rows the whole + * source of truth. */ -TEST_F(ThemeIconRenderTest, PrimaryEmphasisUsesTheAccent) +TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + EXPECT_EQ(IconTint().resolve(QIcon::Normal), themeManager->appColor(AppColor::Icon)); + EXPECT_EQ(IconTint::primary().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconStrong)); + EXPECT_EQ(IconTint::quiet().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconSoft)); + EXPECT_EQ(IconTint(QPalette::WindowText, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(AppColor::IconStrong)); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor primary = IconTint::primary().resolve(QIcon::Normal); + struct Family + { + AppColor::Role base; + AppColor::Role strong; + AppColor::Role soft; + }; - EXPECT_NE(primary, secondary); - EXPECT_GT(primary.blue(), primary.red()) << "the primary glyph did not take the accent's blue"; - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), ThemeIconInk::primaryContrastFloor()); - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), - ThemeIconInk::contrastRatio(secondary, surfaceColor())); + for (const Family &family : {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, + Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(family.strong)); + EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base)); + EXPECT_EQ(IconTint(family.base, IconEmphasis::Quiet).resolve(QIcon::Normal), + themeManager->appColor(family.soft)); + // Naming a variant is explicit: emphasis does not rewrite it. + EXPECT_EQ(IconTint(family.strong, IconEmphasis::Quiet).resolve(QIcon::Normal), + themeManager->appColor(family.strong)); + EXPECT_EQ(IconTint(family.soft, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(family.soft)); + } } /** - * @brief The primary level out-reads the default one on both schemes. + * @brief The authored primary level is the most chromatic member of its family. * - * The default ink borrows enough accent to sit off the palette's pole (5.4:1 on - * light, 6.4:1 on dark), so the 7:1 primary floor is measurably above it in both - * schemes instead of being lost against a near-black or near-white default. - * Hue alone would not keep the order; the contrast target does. + * Contrast cannot order the ladder: on a dark surface the highest-contrast tone + * is the palest one, so a contrast target makes the primary read as a washed-out + * secondary. Chroma orders the levels instead; contrast only has to stay above + * the legibility floor on every surface the icon is drawn on. */ -TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) +TEST_F(ThemeIconRenderTest, PrimaryIsTheMostChromaticLevelOnBothSchemes) { for (const bool dark : {false, true}) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark)); - const QColor surface = surfaceColor(); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor primary = IconTint::primary().resolve(QIcon::Normal); - EXPECT_NE(primary, secondary) << (dark ? "dark" : "light"); - EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface)) - << (dark ? "dark" : "light"); - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) - << (dark ? "dark" : "light"); + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light"); + const QColor secondary = palette.roles.value(AppColor::Icon); + const QColor primary = palette.roles.value(AppColor::IconStrong); + ASSERT_TRUE(secondary.isValid() && primary.isValid()) << (dark ? "dark" : "light"); + + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); + for (const QColor &surface : {palette.window, palette.button}) { + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) + << (dark ? "dark" : "light"); + } } } -/** @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) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light"); + const QColor secondary = palette.roles.value(AppColor::Icon); + const QColor quiet = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(secondary.isValid() && quiet.isValid()) << (dark ? "dark" : "light"); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor quiet = IconTint::quiet().resolve(QIcon::Normal); - - EXPECT_LT(ThemeIconInk::contrastRatio(quiet, surfaceColor()), - ThemeIconInk::contrastRatio(secondary, surfaceColor())) - << "the quiet glyph does not recede"; - EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surfaceColor()), ThemeIconInk::quietContrastFloor()); + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); + for (const QColor &surface : {palette.window, palette.button}) { + 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"); + } + } } -/** @brief Emphasis changes how loud a semantic color is, never which color it is. */ +/** @brief Every authored emphasis level keeps a semantic family's hue. */ TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + 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()); - for (const IconEmphasis emphasis : {IconEmphasis::Primary, IconEmphasis::Secondary, IconEmphasis::Quiet}) { - const QColor ink = IconTint(AppColor::Positive, emphasis).resolve(QIcon::Normal); - EXPECT_GT(ink.green(), ink.red()) << static_cast(emphasis); - EXPECT_GT(ink.green(), ink.blue()) << static_cast(emphasis); + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QMap roles = shippedAppColors(theme, dark); + + for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}) { + const QColor ink = roles.value(role); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.green(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + const QColor ink = roles.value(role); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.red(), ink.blue()) << theme.toStdString() << static_cast(role); + } + } + } + EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level"; +} + +/** + * @brief Every palette-shipping theme authors the whole emphasis ladder. + * + * The palette editor treats an unset role as "derive it", so a missing key is + * not a crash: it silently reintroduces the computed colors this change exists + * to remove. Treat a missing role as a test failure instead, and keep the same + * contrast contract the old computed levels had. + */ +TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) +{ + 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; // System ships no palette, so it has no surfaces to measure. + } + ++checked; + + for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft, + AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, + AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + ASSERT_TRUE(palette.roles.contains(role)) + << theme.toStdString() << (dark ? " dark" : " light") << static_cast(role); + } + + for (const QColor &surface : {palette.window, palette.button}) { + const double secondary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::Icon), surface); + const double primary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconStrong), surface); + const double quiet = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconSoft), surface); + EXPECT_GE(secondary, ThemeIconInk::labelContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(palette.roles.value(AppColor::IconStrong).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor()) + << 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}}) { + EXPECT_GE(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), + ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + 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"); + } + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; + + // The System theme ships no palette of its own, so its families have no + // authored surfaces to measure against. It nevertheless authors the + // semantic family, while the neutral family stays unauthored on purpose: + // the palette file is chosen from the theme's scheme, which is always light + // on Qt < 6.5 and can be unknown on newer Qt, so one fixed ladder would be + // invisible against a platform palette of the other scheme. + for (const bool dark : {false, true}) { + const QMap system = shippedAppColors(QStringLiteral("System"), dark); + EXPECT_TRUE(system.contains(AppColor::PositiveStrong)); + EXPECT_TRUE(system.contains(AppColor::NegativeSoft)); + for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}) { + EXPECT_FALSE(system.contains(role)) << (dark ? "dark" : "light") << static_cast(role); + } } } -/** @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 The System fallback family keeps the ladder on a platform palette. + * + * System authors no palette and follows the OS surfaces, so its families + * cannot be measured against its own window and button. These are the + * representative extremes of a platform palette, and the fixed values have to + * clear the same floor there that the five authored themes clear on their own + * surfaces. + */ +TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces) +{ + struct Scheme + { + bool dark; + QColor window; + QColor button; + }; + const QList schemes = { + {false, QColor(QStringLiteral("#f0f0f0")), QColor(QStringLiteral("#f5f5f5"))}, + {true, QColor(QStringLiteral("#2b2b2b")), QColor(QStringLiteral("#3c3c3c"))}, + }; + + for (const Scheme &scheme : schemes) { + for (const auto &[base, strong, soft] : + {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + const QColor secondary = defaultAppColor(base, scheme.dark); + const QColor primary = defaultAppColor(strong, scheme.dark); + const QColor quiet = defaultAppColor(soft, scheme.dark); + ASSERT_TRUE(secondary.isValid() && primary.isValid() && quiet.isValid()); + + for (const QColor &surface : {scheme.window, scheme.button}) { + EXPECT_GE(ThemeIconInk::contrastRatio(secondary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::labelContrastFloor()); + } + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()); + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()); + } + } +} + +/** + * @brief Every emphasis keeps the selected-row swap to HighlightedText. + */ TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText) { + const QColor tone = IconTint(AppColor::AccentSoft).resolve(QIcon::Normal); + ASSERT_TRUE(tone.isValid()); + QPalette palette = qApp->palette(); const QColor selectedText(QStringLiteral("#222222")); - palette.setColor(QPalette::Active, QPalette::Highlight, QColor(QStringLiteral("#000000"))); + palette.setColor(QPalette::Active, QPalette::Highlight, tone); palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); qApp->setPalette(palette); - // Black ink on a black highlight is illegible, so the selection wins for - // both the default and the quiet level. + // Tone on a highlight of the same color is illegible, so the selection wins + // for both the default and an explicitly tinted glyph. EXPECT_EQ(IconTint().resolve(QIcon::Selected), selectedText); - EXPECT_EQ(IconTint::quiet().resolve(QIcon::Selected), selectedText); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Selected), selectedText); } /** @@ -1487,19 +1887,27 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) constexpr int SIZES[] = {16, 24}; constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; - constexpr IconEmphasis LEVELS[] = {IconEmphasis::Quiet, IconEmphasis::Secondary, IconEmphasis::Primary}; struct Family { const char *name; const char *glyphs[3]; - AppColor::Role role; - bool neutral; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; }; const QList families = { - {"Neutral", {"settings", "info", "player"}, AppColor::Positive, true}, - {"Positive", {"increment", "ready_start", "arrow_up"}, AppColor::Positive, false}, - {"Negative", {"decrement", "not_ready_start", "remove_row"}, AppColor::Negative, false}, + {"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"), @@ -1531,16 +1939,14 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) // 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) { - QColor base; - if (family.neutral) { - base = level == 2 ? palette.accent - : ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); - } else { - base = ThemeIconFill::saturated(defaultAppColor(family.role, dark)); - } - return ThemeIconInk::forEmphasis(base, palette.window, LEVELS[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); @@ -1609,14 +2015,15 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) 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); - } + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, 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); @@ -1637,3 +2044,199 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) } } } + +/** + * @brief Writes the authored emphasis sheets for every shipped palette theme. + * + * The ladder is only useful if it survives the treatment a call site chooses, so + * 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. 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. + */ +TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + 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 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 *glyphs[3]; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; + }; + const QList families = { + {"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"), + }; + + const auto roleAt = [](const Family &family, int level) { + return level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary; + }; + const auto schemeFor = [](bool dark) { return dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; }; + 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); + renderer.render(&painter); + return pixmap; + }; + + 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()) * 4 * 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(); + 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); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, authored emphasis across treatments") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + QColor text; + }; + const QList surfaces = { + {"Window surface", palette.window, palette.text}, + {"Button surface", palette.button, palette.buttonText}, + }; + + 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); + + 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 < 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 << " " << 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), levelName)); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + if (!disabled && bottom != ink) { + painter.drawText(QPointF(contentLeft + 82, y + 28), + QStringLiteral("bottom ") + bottom.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]; + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (disabled || treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, schemeFor(dark)); + }); + 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( + QStringLiteral("emphasis-treatments-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index 7e769b1bd..3bd8be3a2 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -155,13 +155,28 @@ TEST_F(ThemeMigrationTest, IsIdempotent) TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) { - EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b"))); + EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1a7d3a"))); EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#007e28"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#32c05c"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#4c7659"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#8ab196"))); EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e"))); EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80"))); - // Accent roles are derived from the palette, so they have no static default. - EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, false).isValid()); - EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, true).isValid()); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#da0b00"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b"))); + // Accent and neutral icon roles have no static default: an older theme + // written before the roles existed falls back to deriving them from its + // palette rather than pinning one scheme's ink. + for (const bool dark : {false, true}) { + EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::Icon, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::IconStrong, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::IconSoft, dark).isValid()); + } } TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) @@ -170,10 +185,20 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) ASSERT_TRUE(dir.isValid()); PaletteConfig config; - const QColor positive(QStringLiteral("#1b7f3b")); - const QColor negative(QStringLiteral("#b3261e")); - config.appColors[AppColor::Positive] = positive; - config.appColors[AppColor::Negative] = negative; + const QMap roles = { + {AppColor::Icon, QColor(QStringLiteral("#2f59ad"))}, + {AppColor::IconStrong, QColor(QStringLiteral("#213e79"))}, + {AppColor::IconSoft, QColor(QStringLiteral("#687899"))}, + {AppColor::Positive, QColor(QStringLiteral("#1b7f3b"))}, + {AppColor::PositiveStrong, QColor(QStringLiteral("#104a22"))}, + {AppColor::PositiveSoft, QColor(QStringLiteral("#5d7f6f"))}, + {AppColor::Negative, QColor(QStringLiteral("#b3261e"))}, + {AppColor::NegativeStrong, QColor(QStringLiteral("#7c1b15"))}, + {AppColor::NegativeSoft, QColor(QStringLiteral("#9a6c6f"))}, + }; + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + config.appColors[it.key()] = it.value(); + } const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml")); QFile file(path); @@ -182,10 +207,10 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) file.close(); const PaletteConfig parsed = PaletteConfig::fromFile(path); - ASSERT_TRUE(parsed.appColors.contains(AppColor::Positive)); - ASSERT_TRUE(parsed.appColors.contains(AppColor::Negative)); - EXPECT_EQ(parsed.appColors.value(AppColor::Positive), positive); - EXPECT_EQ(parsed.appColors.value(AppColor::Negative), negative); + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + ASSERT_TRUE(parsed.appColors.contains(it.key())); + EXPECT_EQ(parsed.appColors.value(it.key()), it.value()); + } } // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and