Address review findings

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

View file

@ -458,17 +458,18 @@ QColor defaultAppColor(AppColor::Role role, bool dark)
switch (role) {
case AppColor::AccentStrong:
case AppColor::AccentSoft:
// The neutral icon family is palette-derived like the accent, so a
// theme without an authored icon family keeps following the platform
// instead of pinning one scheme's ink.
// 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 without stored values (System) has no per-surface
// palette to measure against, so these are authored against the
// representative platform surfaces the System theme runs on.
// 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("#1a7d3a"));
case AppColor::PositiveStrong:

View file

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

View file

@ -64,10 +64,19 @@ AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis)
*
* The fill needs the surface scheme to choose the badge glyph ink, and it cannot
* read it from the tone: an authored quiet level is a dark color on the dark
* scheme too.
* scheme too. The theme's resolved scheme is the same source the palette file
* and the default app colors are chosen from, so the fill follows it instead of
* re-deriving the scheme from the live Window lightness, which can disagree
* when a theme pins a scheme the platform palette does not match.
*/
ThemeIconFill::Scheme activeScheme()
{
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;

View file

@ -104,11 +104,11 @@ public:
* hue and saturation stay the author's.
*
* Neutral label roles (WindowText, Text) resolve to icon ink rather than the
* raw palette color: ThemeIconInk::glyphInk derives it from the accent 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.
* 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 selects the family's authored variant, so the palette editor's
* rows are the whole source of truth. A caller that names a variant

View file

@ -177,26 +177,50 @@ double targetLuminance(const QColor &surface, double ratio)
*
* Luminance is monotone in HSV value at a fixed hue and saturation, so a
* bisection finds the boundary: the closest tone to the surface that still
* clears the floor, on either scheme. Returns an invalid color when even full
* value cannot reach the floor at that saturation.
* 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);
if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, 255)) < target) {
// Toward black a tone is legible at or below the target luminance, toward
// white at or above it. Testing the wrong side is what let a saturated
// violet at full value look "too dark" on a light surface and fall back to
// the unchanged secondary.
const auto passes = [hue, saturation, target, towardBlack](int value) {
const double luminance = ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, value));
return towardBlack ? luminance <= target : luminance >= target;
};
if (passes(255)) {
// Toward black, full value is the palest tone: if even that clears the
// floor, the whole ramp does and the surface wants the authored tone.
// Toward white it is the palest tone but also the most legible one, so
// the search still starts from the failing end.
if (towardBlack) {
return QColor::fromHsv(hue, saturation, 255).toRgb();
}
} else if (!towardBlack) {
// Toward white the floor needs the palest tone; no darker value can
// reach it at this saturation.
return {};
}
int low = 0;
int high = 255;
int passing = towardBlack ? 0 : 255;
int failing = towardBlack ? 255 : 0;
for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) {
const int middle = (low + high) / 2;
if (ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, middle)) >= target) {
high = middle;
const int middle = (passing + failing) / 2;
if (passes(middle)) {
passing = middle;
} else {
low = middle;
failing = middle;
}
}
return QColor::fromHsv(hue, saturation, high).toRgb();
return QColor::fromHsv(hue, saturation, passing).toRgb();
}
/**

View file

@ -24,7 +24,9 @@
* on. The chroma is what makes it read as an icon rather than as leftover text;
* 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
@ -104,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());

View file

@ -656,14 +656,17 @@ QColor ThemeManager::appColor(AppColor::Role role) const
case AppColor::IconStrong:
case AppColor::IconSoft: {
// Legacy fallback for a theme written before the icon roles
// existed, or one with no authored icon family. The three levels
// are the ink rule this series shipped, applied once here so
// authored roles never get recomputed at paint time.
// 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);
const QColor ink = ThemeIconInk::glyphInk(text, surface, accent, 0.0);
switch (role) {
case AppColor::IconStrong:
return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,21 +1,19 @@
# The System theme derives all of its colors from the platform palette
# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette
# overrides. Only the identity, neutral-ink 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 authored instead of derived: deriving it from the
# platform palette picked up the scheme's accent chroma, which painted neutral
# chrome blue. Neutral ink stays neutral here, black on the light scheme and
# white on the dark one, regardless of what the platform's accent carries.
# 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
Icon = #ffe0e0e0
IconStrong = #ffffffff
IconSoft = #ffb0b0b0
Positive = #ff6dd58c
PositiveStrong = #ff32c05c
PositiveSoft = #ff8ab196
Negative = #ffff8a80
NegativeStrong = #ffff8378
NegativeSoft = #ffc79e9b
NegativeSoft = #ffc79e9b

View file

@ -1,21 +1,19 @@
# The System theme derives all of its colors from the platform palette
# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette
# overrides. Only the identity, neutral-ink 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 authored instead of derived: deriving it from the
# platform palette picked up the scheme's accent chroma, which painted neutral
# chrome blue. Neutral ink stays neutral here, black on the light scheme and
# white on the dark one, regardless of what the platform's accent carries.
# 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
Icon = #ff1a1a1a
IconStrong = #ff000000
IconSoft = #ff5f5f5f
Positive = #ff1a7d3a
PositiveStrong = #ff007e28
PositiveSoft = #ff4c7659
Negative = #ffb3261e
NegativeStrong = #ffda0b00
NegativeSoft = #ff935f5c
NegativeSoft = #ff935f5c

View file

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

View file

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