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
249 lines
13 KiB
QML
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
|
|
}
|