Files
qbz/crates/qbz-qt/qml/theme/QbzIcon.qml
T
vicrodh 81c43a114e fix(qt): glyph colours follow the theme, on light palettes and on accent fills
Two defects with one cause: a tint name that means a frozen hex.

"primary" is the legacy alias of literal #ffffff, not Theme.text-primary,
so every control passing it drew a white glyph. On the eleven light
themes that is white-on-light -- the header back/forward chevrons, the
sidebar toggle and the carousel arrows the owner marked. They now pass
the theme-following names, which icon_tint_qt bakes at runtime.

The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that
predate the runtime tinter collapse into those names, and the qrc floor
underneath them becomes polarity-aware: dir_for() returns "" whenever
the cache dir is unavailable, a bake fails or the theme document has not
been published, and that path used to resolve textPrimary straight back
to the white bake -- reproducing the bug it was fixing, app-wide, in the
degraded state.

On accent fills the fix is a deviation from Slint, approved by the owner
after measurement. A fixed white glyph on Theme.accent falls under 2.6:1
on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and
wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast.
Slint hardcodes #ffffff there (CircleAction.slint:72 and three others),
so the bug is upstream. accentText alone is not the answer either: it is
picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn,
worse than the black it replaces. So QbzTheme picks the best-contrast
tint by real sRGB luminance against the live accent, and the labels
beside those glyphs use its colour twin -- BrowseGenreButton and
LibraryView were drawing a white glyph next to an accentText label
inside the same pill.

Rounds out the two parity items the same files owe: QbzNavButton gains
the ambient arm from Carousel.slint (wired at the six carousel sites,
not left dead), and QbzCircleAction rounds its glyph to whole pixels the
way CircleAction.slint does.

cc
2026-07-29 10:30:43 -06:00

249 lines
13 KiB
QML

// Tinted SVG icon — the QML equivalent of Slint's QbzIcon (glyphs recolored
// to a theme tint).
//
// HOW TINTING WORKS HERE (and why it is not what Slint does)
//
// Slint's QbzIcon is `Image { colorize: <live Theme token> }`: the renderer
// recolours the glyph every frame, so an icon always matches the current
// theme, all 36 of them, custom accent included. Qt has no equivalent on the
// path this port runs on — ColorOverlay / MultiEffect / shaders draw NOTHING
// on the software renderer (verified with a probe during the POC), which is
// why this file used to point straight at PRE-BAKED variants,
// assets/icons/<tint>/<name>.svg, each with a fixed hex burned in.
//
// Fixed hexes cannot serve 36 themes: the bakes were cut against the dark
// palette, so `secondary` (#cccccc) and `muted` (#888888) were near-white
// glyphs on near-white surfaces on every LIGHT theme, and `accent` was
// #4285f4 whatever the theme's accent actually was.
//
// So the tinted set is now GENERATED AT RUNTIME, into the user cache dir, by
// src/icon_tint_qt.rs: `QbzIconTint.dirFor(tint, themeJson)` returns the
// file:// directory holding this tint's glyphs for the live theme, and the
// Image loads a real .svg off disk — byte for byte the same load path as the
// qrc bakes, no effects involved. Passing `QbzShell.themeJson` is what makes
// the binding re-resolve on a live theme switch (the same dependency trick as
// `QbzSession.tr(msgid, trRev)`), and it is the colour source at the same
// time.
//
// ---------------------------------------------------------------------------
// THE TINT VOCABULARY — read this before adding a call site
// ---------------------------------------------------------------------------
//
// THEME-FOLLOWING (runtime-tinted, correct under all 36 themes):
// "textPrimary" Theme.text-primary — max contrast on a THEME surface
// "secondary" Theme.text-secondary (alias: "textSecondary")
// "muted" Theme.text-muted (alias: "textMuted")
// "disabled" Theme.text-disabled (alias: "textDisabled")
// "accent" Theme.accent
// "accentText" the glyph colour ON an accent fill
// "warning" Theme.warning
// "favorite" Theme.favorite
//
// FIXED (theme-independent by design, served from the qrc bakes):
// "white" a light glyph on a host that is dark under EVERY theme:
// artwork scrims (#a6000000 / #b3000000), gradient discs,
// the close button's #e81123 hover
// "primary" LEGACY ALIAS OF "white" — a literal #ffffff, NOT
// Theme.text-primary. The app-wide migration is done: the
// fixed-host call sites now say "white" and the ones that
// always meant the theme token say "textPrimary". Only two
// call sites still spell it "primary"
// (controls/BrowseGenreButton.qml, views/LibraryView.qml —
// the glyph on an accent fill, where Slint hardcodes
// #ffffff but the sibling LABEL uses Theme.accent-text, so
// the intent is genuinely ambiguous and is the owner's
// call). The alias stays so those two keep working; do not
// add new ones.
// "black" a dark glyph on a host painted light under every theme
// "amber" #e0b341, the audio stamp's brand accent (one icon)
//
// Getting this pair wrong is how the bug this file fixes happened in reverse:
// resolving "primary" to Theme.text-primary would paint a DARK glyph on a
// dark scrim on light themes. Slint keeps the two apart explicitly
// (CircleAction.slint:70-74) and so does this.
//
// ---------------------------------------------------------------------------
// THE QRC BAKES REMAIN, AS THE FLOOR
// ---------------------------------------------------------------------------
//
// `dirFor` returns "" — and this file falls back to assets/icons/<dir>/ —
// whenever the tint is fixed, there is no writable cache dir, the bake
// failed, the theme document has not been published yet, or QbzIconTint is
// not registered at all (see the `typeof` guard: an unregistered singleton
// resolves LAZILY and would otherwise take every icon in the app down with
// it). It also falls back per-icon on Image.Error, so a name that exists in
// the qrc set but not in the runtime masters degrades to the old fixed colour
// instead of rendering nothing. Verified against src/icon_tint_qt.rs: every
// one of those paths is a real `QString::default()` return (`tint_root()`
// None, `bake()` false, empty/tokenless theme_json), plus one more that is
// not on the list — `prune()` sweeps the OLDEST directories past
// MAX_TINT_DIRS = 32, and a dir reused from a previous session keeps its old
// mtime because `bake()` early-returns on the `.complete` stamp, so the
// CURRENT theme's dir can be deleted under the running app and every Image
// 404s into `qrcFallback`. The floor is not a formality; it is reachable.
//
// AND THE FLOOR HAS TO CARRY THE POLARITY. Mapping a theme-following tint to
// a fixed dir is only harmless while the runtime bake is alive: send
// "textPrimary" to primary/ (a literal #ffffff) and the degraded path paints
// white-on-near-white across the 11 light themes, at ~35 call sites — the
// exact bug the runtime tinting exists to fix. So `qrcTint` below picks its
// dir from the LIVE token, with the same `hslLightness > 0.5` test the call
// sites carried before they were collapsed into this vocabulary.
//
// THE ONE WAY A BAKE STILL SILENTLY LIES — worth keeping in mind when adding
// an icon: NO VARIANT ON DISK -> the Image resolves to nothing and the glyph
// is simply ABSENT, with nothing logged. The runtime sets are always complete
// (every master, every tint); the qrc dirs were NOT, which is why the floor
// could not be trusted: `black/` and `warning/` were missing mp3 + translate,
// `secondary/` and `accent/` were missing mp3 (and `black/cd.svg` was a
// #cccccc glyph — a light-grey disc in the DARK-glyph directory). All six
// neutral/chromatic dirs are now 96 files, the same set as primary/ and as
// MASTERS; only `favorite/` (52) and `amber/` (1) stay deliberately partial,
// and neither is reachable from the polarity mapping. A new icon goes into
// assets/icons/primary/, into the MASTERS table in src/icon_tint_qt.rs, AND
// into black/ + secondary/ + muted/ if any theme-following tint can ask for
// it.
import QtQuick
import com.blitzfc.qbz
Image {
id: root
// Icon file name, e.g. "compass" (".svg" appended).
property string name: ""
// One of the names in the vocabulary above.
property string tintName: "secondary"
// The HSL lightness test for one theme token, read straight out of the
// live document without parsing it. `QbzShell.themeJson` is COMPACT serde
// output (theme_qt.rs:266 `serde_json::to_string`) and every colour goes
// through `argb()` (theme_qt.rs:124-126), so a token always reads exactly
// "textPrimary":"#ff0f0f0f"
// Reading it costs one indexOf over ~2.7 KB — next to nothing beside the
// QbzIconTint.dirFor() round trip this file already makes per icon (which
// hashes that whole string). The alternatives were both per-icon and much
// worse: a JSON.parse here, or a `QbzTheme { }` instance inside QbzIcon —
// ~70 eager bindings AND a parse each, multiplied by the hundreds of
// icons alive at once and re-run on every theme switch. QbzIcon is the
// one component in this tree that cannot afford an owned QbzTheme.
//
// Missing / unparsable -> true (light), which is the same default
// QbzTheme.qml takes pre-publish: its baked fallback document is the DARK
// palette, whose text-primary IS #ffffff.
function tokenIsLight(key) {
// Also the binding dependency — property reads are captured through
// the call, so `qrcTint` re-resolves when the theme is republished.
const json = QbzShell.themeJson
const needle = '"' + key + '":"#'
const at = json.indexOf(needle)
if (at < 0)
return true
const end = json.indexOf('"', at + needle.length)
if (end < 0)
return true
const hex = json.substring(at + needle.length, end)
if (hex.length < 6)
return true
// Drop the alpha byte of #aarrggbb (6-digit values work unchanged).
const o = hex.length - 6
const r = parseInt(hex.substring(o, o + 2), 16)
const g = parseInt(hex.substring(o + 2, o + 4), 16)
const b = parseInt(hex.substring(o + 4, o + 6), 16)
if (isNaN(r) || isNaN(g) || isNaN(b))
return true
// (max + min) / 2 over 0..255 — `color.hslLightness > 0.5`, the
// metric the per-view ternaries used before the vocabulary landed.
return (Math.max(r, g, b) + Math.min(r, g, b)) / 510 > 0.5
}
// The qrc directory this tint falls back to. Every tint name — including
// the ones with no directory of their own ("white", "textPrimary",
// "accentText" ...) — must land on a REAL dir, or the fallback renders
// nothing at all. And a theme-following name must land on a dir whose
// POLARITY matches the token it stands for (see the header): this is the
// floor, so it is what a light theme gets when the bake is unreachable.
readonly property string qrcTint: {
switch (root.tintName) {
// Fixed by design — a light glyph on a host that is dark under every
// theme (scrims, gradient discs, the #e81123 close hover). "primary"
// is the legacy alias of "white" and stays a literal #ffffff.
case "white":
case "primary":
return "primary"
// Theme-following. Both of these read their OWN token, never the
// theme polarity: accent-text is near-BLACK on plenty of DARK themes
// whose accent fill is bright (qbz-theme registry.rs — tokyo-night
// #1a1b26, catppuccin-mocha #1e1e2e, ...), so `isDark` would get it
// backwards in both directions.
case "textPrimary":
return root.tokenIsLight("textPrimary") ? "primary" : "black"
case "accentText":
return root.tokenIsLight("accentText") ? "primary" : "black"
// The weak text ramp: #cccccc is unreadable on a light theme's
// surfaces, #888888 is the mid-grey that survives both polarities.
case "secondary":
case "textSecondary":
return root.tokenIsLight("textSecondary") ? "secondary" : "muted"
// muted / disabled stay on muted/ under BOTH polarities on purpose.
// #888888 reads on white and on near-black alike, and it is the only
// mid-grey bake there is: routing these by lightness would send the
// dark themes' text-disabled (#555555, lightness 0.33) to black/ —
// an invisible glyph on a dark surface, the bug in mirror image.
case "muted":
case "textMuted":
case "textDisabled":
case "disabled":
return "muted"
// "accent", "warning", "favorite" (chromatic, legible on both), and
// the fixed "black" / "amber" — each already names its own dir.
default:
return root.tintName
}
}
// Absolute qrc URL: relative URLs in a shared component resolve
// against the CONSUMER's document depth (phase-23 dir layout — root
// consumers underflowed one level).
readonly property string qrcDir:
"qrc:/qt/qml/com/blitzfc/qbz/qml/assets/icons/" + root.qrcTint
// The runtime-tinted directory for this tint under the live theme, or ""
// when the qrc bake must be used (see above).
readonly property string tintDir: {
// Dependency: re-resolve every icon when the theme is republished.
const themeJson = QbzShell.themeJson
// `typeof` on an unregistered type name does not throw (plain JS
// global lookup), so a missing registration degrades to the bakes
// instead of poisoning every source binding with `undefined`.
if (typeof QbzIconTint === "undefined")
return ""
const dir = QbzIconTint.dirFor(root.tintName, themeJson)
return (typeof dir === "string") ? dir : ""
}
// Set once, per icon, if the runtime set has no such glyph (a master the
// table does not carry yet). Cleared whenever the inputs change so a
// recycled delegate is never stuck on the fallback.
property bool qrcFallback: false
onNameChanged: root.qrcFallback = false
onTintNameChanged: root.qrcFallback = false
onTintDirChanged: root.qrcFallback = false
readonly property string activeDir:
(root.tintDir === "" || root.qrcFallback) ? root.qrcDir : root.tintDir
source: root.name === "" ? "" : root.activeDir + "/" + root.name + ".svg"
onStatusChanged: {
if (status === Image.Error && !root.qrcFallback && root.tintDir !== "")
root.qrcFallback = true
}
sourceSize: Qt.size(Math.max(1, Math.round(width * 2)), Math.max(1, Math.round(height * 2)))
fillMode: Image.PreserveAspectFit
asynchronous: false
}