diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp index 1b0748091..f74dc2010 100644 --- a/cockatrice/src/interface/theme_icon_ink.cpp +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -14,13 +14,13 @@ namespace constexpr double ACCENT_MIX = 0.70; // 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 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; // 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; @@ -105,11 +105,59 @@ double labelContrastFloor() return LABEL_CONTRAST_FLOOR; } +namespace +{ +/** @brief The luminance a color needs to reach @p ratio against @p surface. */ +double targetLuminance(const QColor &surface, double ratio) +{ + const double surfaceLuminance = ThemeIconInk::relativeLuminance(surface); + return surfaceLuminance > 0.5 ? (surfaceLuminance + 0.05) / ratio - 0.05 : (surfaceLuminance + 0.05) * ratio - 0.05; +} + +/** + * @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 double target = targetLuminance(surface, floor); + const int hue = color.hue(); + const int secondary = color.hsvSaturation(); + const int primary = qMin(255, secondary + stepPoints); + for (int saturation = primary; saturation > secondary; --saturation) { + if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, 255)) < target) { + continue; + } + int low = 0; + int high = 255; + for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { + const int middle = (low + high) / 2; + if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, middle)) >= target) { + high = middle; + } else { + low = middle; + } + } + return QColor::fromHsv(hue, saturation, high).toRgb(); + } + return ThemeIconInk::reachContrast(color, surface, floor); +} +} // 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(); case IconEmphasis::Secondary: @@ -120,7 +168,7 @@ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emph double primaryContrastFloor() { - return PRIMARY_CONTRAST; + return LABEL_CONTRAST_FLOOR; } double quietBlend() diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h index 6ba90a239..f1af04184 100644 --- a/cockatrice/src/interface/theme_icon_ink.h +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -99,11 +99,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 + * Secondary keeps the color untouched, so it is the no-op the default needs. + * Primary is the most chromatic member of its family: it raises the saturation + * to the most that still clears the text floor. Contrast alone cannot order the + * ladder, because on a dark surface the highest-contrast tone is the palest one; + * a primary that reads as a washed-out version of the secondary is exactly the + * inversion this rule removes. Quiet * blends the color toward the surface it sits on and stops at the non-text * legibility floor, so a labelling glyph recedes without disappearing. * @@ -114,7 +115,7 @@ 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 Contrast floor the primary level reaches, so it stays legible. */ double primaryContrastFloor(); /** @brief How far a quiet glyph is blended into its surface. */ diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index 2412f7c03..916a27ea3 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Icon = #7b9fe4 -IconStrong = #b1c7ef +Icon = #7ca0e5 +IconStrong = #6a9dff IconSoft = #70809d -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #5bb174 +PositiveStrong = #30b758 PositiveSoft = #5e8873 -Negative = #ff8a80 -NegativeStrong = #ffb3ac +Negative = #ed8077 +NegativeStrong = #ff7367 NegativeSoft = #9b7678 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index f122a5aeb..8a27f4c30 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Icon = #2f59ad -IconStrong = #213e79 +Icon = #2f58ac +IconStrong = #054ddf IconSoft = #687899 -Positive = #1b7f3b -PositiveStrong = #104a22 +Positive = #166931 +PositiveStrong = #006b22 PositiveSoft = #5d7f6f -Negative = #b3261e -NegativeStrong = #7c1b15 +Negative = #b0251d +NegativeStrong = #ba0a00 NegativeSoft = #9a6c6f diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index ece7633ac..037eab5d0 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #ff1e1e1e [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 -Icon = #5db87c -IconStrong = #aad9b9 +Icon = #5eb97d +IconStrong = #30be61 IconSoft = #6e8d79 -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #5fb97a +PositiveStrong = #32bf5c PositiveSoft = #698e74 -Negative = #ff8a80 -NegativeStrong = #ffbfb9 +Negative = #f8867c +NegativeStrong = #ff8075 NegativeSoft = #a37c78 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 3465168eb..cdf5a6a7f 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #fff0f0f0 [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 -Icon = #107e36 -IconStrong = #0b5826 +Icon = #107d36 +IconStrong = #007e2c IconSoft = #6c937a -Positive = #1b7f3b -PositiveStrong = #135929 +Positive = #1b7d3a +PositiveStrong = #007e28 PositiveSoft = #70927b -Negative = #b3261e -NegativeStrong = #7d1b15 +Negative = #cf2c23 +NegativeStrong = #da0c00 NegativeSoft = #b37d7b diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index ab5fe82b7..a613da217 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Icon = #bc8c4a -IconStrong = #d4b68c +Icon = #bd8d4a +IconStrong = #d0851e IconSoft = #86745a -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #55a66d +PositiveStrong = #2dab52 PositiveSoft = #588061 -Negative = #ff8a80 -NegativeStrong = #ffada6 +Negative = #df7970 +NegativeStrong = #ff5e50 NegativeSoft = #996b66 diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index df1d50978..4469c127b 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Icon = #735125 -IconStrong = #4e3719 +Icon = #725025 +IconStrong = #7e4b09 IconSoft = #817059 -Positive = #1b7f3b -PositiveStrong = #0f4520 +Positive = #15642e +PositiveStrong = #006621 PositiveSoft = #61795d -Negative = #b3261e -NegativeStrong = #741914 +Negative = #a7231c +NegativeStrong = #b10a00 NegativeSoft = #9a665b diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index 487f0dff4..ee6f9d254 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Icon = #927fea -IconStrong = #b7aaf1 +Icon = #9380eb +IconStrong = #927aff IconSoft = #736b97 -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #519f69 +PositiveStrong = #2ba44f PositiveSoft = #517a63 -Negative = #ff8a80 -NegativeStrong = #ffada6 +Negative = #d5736b +NegativeStrong = #ff4f40 NegativeSoft = #926567 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index 16199858b..b3e96ca75 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Icon = #5439d0 -IconStrong = #39278e +Icon = #5338ce +IconStrong = #4118fe IconSoft = #7569a4 -Positive = #1b7f3b -PositiveStrong = #0e431f +Positive = #15622e +PositiveStrong = #006420 PositiveSoft = #5c776d -Negative = #b3261e -NegativeStrong = #711913 +Negative = #a4231b +NegativeStrong = #ae0900 NegativeSoft = #95646d diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 238a7e40c..b5b43df0a 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,13 +71,13 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Icon = #8392a1 -IconStrong = #acb6c0 +Icon = #8493a2 +IconStrong = #6b95bf IconSoft = #6d737a -Positive = #6dd58c -PositiveStrong = #99e2ae +Positive = #52a16a +PositiveStrong = #2ba650 PositiveSoft = #517c61 -Negative = #ff8a80 -NegativeStrong = #ffada6 +Negative = #d8756c +NegativeStrong = #ff5344 NegativeSoft = #926865 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index a5ba5e959..c61f1e94c 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,13 +71,13 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Icon = #4a5b6f -IconStrong = #333e4c +Icon = #495a6e +IconStrong = #375a84 IconSoft = #6e7580 -Positive = #1b7f3b -PositiveStrong = #0f4620 +Positive = #16662f +PositiveStrong = #006721 PositiveSoft = #5b7b6a -Negative = #b3261e -NegativeStrong = #761a14 +Negative = #aa241c +NegativeStrong = #b40a00 NegativeSoft = #96696b diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 672826462..25253a29d 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1440,13 +1440,14 @@ TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) } /** - * @brief The authored primary level out-reads the default one on both schemes. + * @brief The authored primary level is the most chromatic member of its family. * - * The three levels are authored against the window and button surfaces, so the - * ordering has to be visible on both, not just on the window the palette is - * usually previewed against. + * 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}) { const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); @@ -1455,9 +1456,8 @@ TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) 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_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface)) - << (dark ? "dark" : "light"); EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) << (dark ? "dark" : "light"); } @@ -1559,7 +1559,9 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) << theme.toStdString() << (dark ? " dark" : " light"); EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor()) << theme.toStdString() << (dark ? " dark" : " light"); - EXPECT_GT(primary, secondary) << 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(quiet, secondary) << theme.toStdString() << (dark ? " dark" : " light"); @@ -1573,7 +1575,7 @@ TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) << theme.toStdString() << (dark ? " dark" : " light"); EXPECT_LT(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), baseContrast) << theme.toStdString() << (dark ? " dark" : " light"); - EXPECT_GT(ThemeIconInk::contrastRatio(palette.roles.value(strong), surface), baseContrast) + EXPECT_GT(palette.roles.value(strong).hsvSaturation(), palette.roles.value(base).hsvSaturation()) << theme.toStdString() << (dark ? " dark" : " light"); } }