feat(qt): ambient background port, clip sweep and measurement tooling

Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc),
uncommitted until now: the app-wide dynamic background in both modes
(AmbientField with GPU/Canvas arms, AppShell layering, ambient palette
pipeline), the wave-1 clip/batch sweep across the tree (alpha batches
449 -> 327, reproducible), the rounded-cover and icon-tint arms, and
scripts/qt-batches.sh (batches + presents/s sampler).

The GPU behaviour of these pieces was finalized in the follow-up pulse
commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/
GPU-COST-INVESTIGATION.md for the full campaign.

km
This commit is contained in:
vicrodh committed 2026-08-13 13:01:32 -06:00
1 parent 21e941bdf6
commit b136c9cf5d
60 files changed
+893 -140

No files matched your search

+58 -1
View File
@@ -26,6 +26,44 @@ const SHADER_GLSL: &str = "100 es,300 es,310 es,320 es,120,150,440";
const SHADER_HLSL: &str = "50";
const SHADER_MSL: &str = "12";
/// The same list without `100 es`, for shaders that use unsigned integer
/// arithmetic. GLSL ES 1.00 (OpenGL ES 2.0) has NO uint type, so SPIRV-Cross
/// has to re-type the literals as int and refuses when that would make one
/// negative — `qsb` then fails the whole bake, not just that variant.
///
/// The one shader in this position is `ambient.frag`, and its hash CANNOT drop
/// to floats: the reference's own comment (ambient.wgsl) records that a float
/// hash lets the same lattice corner disagree between adjacent cells and draws
/// a straight seam through the warp field. So the ES 2.0 level is dropped
/// instead, and AmbientField's Canvas arm covers that hardware — which is what
/// the fallback exists for.
const SHADER_GLSL_NO_ES100: &str = "300 es,310 es,320 es,120,150,440";
/// Opt out of `100 es` by putting this marker anywhere in the shader source.
const NO_ES100_MARKER: &str = "QSB-SKIP-GLES100";
/// Is `out` already a current bake of `src`? True only when it exists and is
/// no older than BOTH the shader source and this build script — the script
/// because the variant lists and the `100 es` opt-out live here, so editing
/// them has to invalidate every `.qsb` even though no `.frag` moved.
///
/// Anything unreadable answers false: a bake we cannot reason about is one we
/// redo. Equal mtimes count as current (a same-second write is the normal case
/// right after a bake, and treating it as stale would restart the churn).
fn up_to_date(src: &Path, out: &Path) -> bool {
let mtime = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
let (Some(out_t), Some(src_t)) = (mtime(out), mtime(src)) else {
return false;
};
if out_t < src_t {
return false;
}
match mtime(Path::new("build.rs")) {
Some(script_t) => out_t >= script_t,
None => true,
}
}
/// Locate Qt's `qsb`. PATH first, then the usual install layouts (Linux
/// distro, Homebrew on the Mac mini).
fn find_qsb() -> Option<std::path::PathBuf> {
@@ -71,6 +109,18 @@ fn find_qsb() -> Option<std::path::PathBuf> {
///
/// Missing `qsb` is a WARNING, not an error: the committed `.qsb` are still
/// in the tree and still load, so a box without Qt's shader tools can build.
///
/// THE BAKE MUST BE IDEMPOTENT, and that is not a nicety — it is the whole
/// reason `up_to_date` exists. `qsb` REWRITES its output unconditionally:
/// byte-identical content, brand-new mtime (measured — same md5, mtime moves).
/// The `.qsb` live under `qml/assets`, which `main` watches with
/// `cargo:rerun-if-changed=qml` plus one line per qrc file, so an
/// unconditional bake made every build touch a file cargo was watching. Cargo
/// then re-ran this script on the NEXT invocation, which baked again, which
/// dirtied the watch again: `cargo build` never reached a no-op and every
/// `qt-run.sh` recompiled the crate from the build script down. Introduced in
/// 21e941bdf together with the bake itself, and it is why the tree started
/// rebuilding on every run when nothing had changed.
fn build_shaders() {
let dir = Path::new("qml/assets/shaders");
if !dir.is_dir() {
@@ -91,8 +141,15 @@ fn build_shaders() {
}
println!("cargo:rerun-if-changed={}", src.display());
let out = src.with_extension(format!("{ext}.qsb"));
if up_to_date(&src, &out) {
continue;
}
let glsl = match std::fs::read_to_string(&src) {
Ok(text) if text.contains(NO_ES100_MARKER) => SHADER_GLSL_NO_ES100,
_ => SHADER_GLSL,
};
let mut cmd = std::process::Command::new(&qsb);
cmd.args(["--glsl", SHADER_GLSL, "--hlsl", SHADER_HLSL, "--msl", SHADER_MSL]);
cmd.args(["--glsl", glsl, "--hlsl", SHADER_HLSL, "--msl", SHADER_MSL]);
// Vertex shaders used by Qt Quick need the batching rewrite, or the
// scene graph cannot batch the item and falls back per-node.
if ext == "vert" {
@@ -0,0 +1,182 @@
#version 440
// Ambient — the app-wide dynamic background, a 1:1 GLSL port of
// crates/qbz-ui/ui/shaders/ambient.wgsl (WGSL, wgpu scene 7). Every constant,
// every orbit and every curve below is the same number the reference uses; the
// only differences are mechanical (WGSL -> GLSL 440, uniform block instead of a
// bind group, and `time` arrives as a QML property instead of a shared frame
// uniform).
//
// WHY THIS IS A SHADER AND NOT A CANVAS. The Canvas version this replaces
// painted four radial gradients over a BLACK base, so anywhere outside a blob's
// radius the window was #000000 — measured on the owner's 2026-08-04 side by
// side: Qt at (1600,600) and (1690,880) was (0,0,0) while Slint was (58,51,5)
// and (29,27,10). The reference cannot produce black: `col` is the
// metaball-WEIGHTED album colour (the field divides out, so it is defined
// everywhere) and the darkest it is ever scaled is `mix(0.42, 1.12, shape)` at
// shape = 0. The floor is 42% of the album colour across the WHOLE window,
// which is why the Slint reads as one wash that brightens into lobes and the
// Canvas read as spotlights on black. That floor is not reachable by adding
// gradients — it needs the per-pixel weighted average, i.e. this.
//
// Canvas remains as the fallback arm for the software scene graph (see
// AmbientField.qml), where it now paints the same floor as a flat base.
//
// The uniform block is the ShaderEffect contract: qt_Matrix and qt_Opacity are
// supplied by Qt, everything after them must exist as a `property real` /
// `property color` on the effect item. Qt resolves them BY NAME out of the
// .qsb's reflection data, so the QML declaration order is free — but a name
// with no matching property is silently left at zero, which is why every
// member below is spelled identically on both sides.
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
vec4 primary;
vec4 secondary;
vec4 accent;
float qt_Opacity;
float time;
float levelSmooth;
float resX;
float resY;
float dim;
};
// QSB-SKIP-GLES100 — build.rs drops the `100 es` variant for this file. GLSL ES
// 1.00 has no uint, SPIRV-Cross re-types the hash constants below as int, the
// large ones go negative and qsb fails the WHOLE bake. Dropping the ES 2.0
// level costs nothing here: AmbientField falls back to its Canvas arm wherever
// the effect cannot load, and the hash cannot become float-based (see below).
//
// INTEGER lattice hash (bit-exact), wgsl:hash2. The reference's comment is
// load-bearing and applies verbatim here: float hashes leave the result to f32
// rounding, so the SAME lattice corner hashed from two adjacent cells can
// disagree and draw a "wallpaper join" seam through the warp field. uint
// arithmetic has no rounding.
float hash2(vec2 p) {
uint h = uint(int(p.x)) * 0x8da6b343u + uint(int(p.y)) * 0xd8163841u;
h = (h ^ (h >> 15u)) * 0x2c1b3c6du;
h = (h ^ (h >> 12u)) * 0x297a2d39u;
h = h ^ (h >> 15u);
return float(h >> 8u) * (1.0 / 16777216.0);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash2(i);
float b = hash2(i + vec2(1.0, 0.0));
float c = hash2(i + vec2(0.0, 1.0));
float d = hash2(i + vec2(1.0, 1.0));
vec2 w = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, w.x), mix(c, d, w.x), w.y);
}
float fbm(vec2 p) {
float v = 0.0;
float amp = 0.55;
vec2 q = p;
v += amp * vnoise(q); q *= 2.02; amp *= 0.5;
v += amp * vnoise(q); q *= 2.03; amp *= 0.5;
v += amp * vnoise(q);
return v;
}
// Metaball potential (r^2/d^2): summed over several movers the iso-surface
// merges and splits organically. Unlike a gaussian blob this has a LONG TAIL —
// that tail is what fills the window between the lobes, and it is precisely
// what a canvas radial gradient (which terminates at its radius) cannot do.
float mball(vec2 uv, vec2 c, float r) {
vec2 d = uv - c;
return (r * r) / (dot(d, d) + 0.0009);
}
void main() {
// Aspect-correct so the drift looks even on wide windows. NOTE the space
// this puts us in: x spans [0, aspect] and y spans [0, 1], so the radius
// below is a fraction of the window HEIGHT — the Canvas port had it as
// 0.36 * max(W, H), which on a 1700x1400 window made the blobs 29% too big.
float aspect = resX / max(resY, 1.0);
vec2 uv = qt_TexCoord0;
uv.x *= aspect;
// Clock. Fast enough that the flow is visible in seconds (blob orbits are
// ~10-18 s); `levelSmooth` adds a gentle breathe when audio is flowing.
float t = time * 0.75;
float breathe = 1.0 + 0.12 * levelSmooth;
// Two-octave domain warp. The low octave does the big organic flow; the
// high octave stays small and lower-frequency — a strong high-freq warp
// folds the metaball field into sharp creases that read as hard edges over
// the translucent UI.
vec2 w1 = vec2(
fbm(uv * 1.3 + vec2(t * 0.5, t * 0.2)),
fbm(uv * 1.3 + vec2(-t * 0.3, t * 0.45))
);
vec2 w2 = vec2(
fbm(uv * 2.3 + vec2(-t * 0.6, t * 0.45)),
fbm(uv * 2.3 + vec2(t * 0.55, -t * 0.35))
);
vec2 p = uv + (w1 - 0.5) * 0.68 + (w2 - 0.5) * 0.14;
// Four album-coloured metaballs on big wandering orbits.
vec3 c4 = mix(primary.rgb, accent.rgb, 0.5);
vec2 cA = vec2((0.32 + 0.30 * sin(t * 0.40)) * aspect, 0.42 + 0.30 * cos(t * 0.33));
vec2 cB = vec2((0.66 + 0.30 * sin(t * 0.35 + 2.1)) * aspect, 0.56 + 0.32 * cos(t * 0.29 + 1.3));
vec2 cC = vec2((0.50 + 0.34 * cos(t * 0.31 + 4.0)) * aspect, 0.36 + 0.30 * sin(t * 0.45 + 3.2));
vec2 cD = vec2((0.46 + 0.32 * sin(t * 0.27 + 5.3)) * aspect, 0.64 + 0.28 * cos(t * 0.49 + 0.7));
float rr = 0.34 * breathe;
float fA = mball(p, cA, rr);
float fB = mball(p, cB, rr * 0.95);
float fC = mball(p, cC, rr * 0.88);
float fD = mball(p, cD, rr * 0.82);
float field = fA + fB + fC + fD;
// Metaball-weighted album colour (which lobe dominates here).
vec3 col = (primary.rgb * fA + secondary.rgb * fB + accent.rgb * fC + c4 * fD)
/ (field + 0.0001);
// The amoeba structure: the iso-surface. A WIDE smoothstep so the lobes melt
// into the base over a long soft gradient instead of a hard rim, and a
// gentler bright/dark spread so transitions never snap. 0.42 is the floor
// this whole file exists for.
float shape = smoothstep(0.45, 3.4, field);
col *= mix(0.42, 1.12, shape);
// Push saturation/contrast a little so the lobes read as album colour, not
// a muddy average — but not so hard it re-sharpens the transitions.
float luma = dot(col, vec3(0.299, 0.587, 0.114));
col = mix(vec3(luma), col, 1.28);
// Vertical falloff -> a touch darker at the very top/bottom edges so chrome
// (titlebar, player bar) sits on calmer colour. QUADRATIC, not the linear
// black ramp the Canvas used.
float vshade = 1.0 - 0.16 * pow(abs(qt_TexCoord0.y - 0.5) * 2.0, 2.0);
col *= vshade;
// Overall brightness — vivid; the dim scrim above this layer provides the
// legibility dim, so the base can stay bright without glaring.
col = clamp(col, vec3(0.0), vec3(1.0)) * 0.92;
// A touch of grain (dither) to break up 8-bit banding on the smooth
// gradient — banding rings read as faint hard edges too.
float grain = (vnoise(qt_TexCoord0 * vec2(resX, resY) * 0.5) - 0.5) * 0.022;
col += vec3(grain);
// The legibility scrim, FOLDED IN. The reference paints it as a separate
// full-window black Rectangle at `dim` alpha over the field
// (AppShell.slint:236-241), which is arithmetically `field * (1 - dim)` —
// identical to doing it here, one multiply instead of a whole extra
// full-screen blend pass in every frame the scene redraws. With the
// background up the shell is already an all-alpha stack, so a pass removed
// is a pass removed from EVERY frame, not just the 30 the field owns.
col *= (1.0 - clamp(dim, 0.0, 1.0));
col = clamp(col, vec3(0.0), vec3(1.0));
// Premultiplied, like every other effect in this tree.
fragColor = vec4(col * qt_Opacity, qt_Opacity);
}
Binary file not shown.
+9 -1
View File
@@ -309,7 +309,8 @@ Rectangle {
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
// No clip: every child is geometrically contained and the overlay
// Texts above are now bounded. One batch root per grid card.
RoundedImage {
anchors.fill: parent
@@ -349,6 +350,11 @@ Rectangle {
Text {
visible: root.genre !== ""
text: root.genre
// Bounded because the tile's clip is gone: genre is
// unbounded catalog data and was the ONLY thing that could
// escape the 200px tile. 200 - 2*12 (the Column's x).
width: 176
elide: Text.ElideRight
height: 20
color: "#ebffffff"
font.pixelSize: 13
@@ -358,6 +364,8 @@ Rectangle {
Text {
visible: root.year !== ""
text: root.year
width: 176
elide: Text.ElideRight
height: 17
color: "#ccffffff"
font.pixelSize: 12
+5 -1
View File
@@ -138,7 +138,11 @@ Rectangle {
width: 190
height: 190
radius: 95
clip: true
// No clip. It never made the circle — QML's clip is a
// rectangular scissor and ignores `radius`
// (qquickclipnode.cpp:13-22); the round shape comes from each
// child's own `radius` / RoundedImage mask. Geometrically
// nothing leaves the 190x190 box.
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: theme.surfaceElevated }
+2 -1
View File
@@ -267,7 +267,8 @@ Item {
Item {
id: mosaic
anchors.fill: parent
clip: true
// No clip: cellOf() tiles the parent rect exactly, and each tile is a
// RoundedImage that scissors itself when it needs to.
visible: root.collage
layer.enabled: !root._noShaders && root.radius > 0 && root.collage
layer.smooth: true
+5 -1
View File
@@ -92,7 +92,11 @@ Rectangle {
width: 190
height: 190
radius: 95
clip: true
// No clip. It never clipped to the circle — QML's clip is a
// rectangular scissor and ignores `radius`; the round shape is
// each child's own `radius: 95` / RoundedImage mask. The logo
// is inset 28px with fit "contain" and the overlay row ends at
// y=157 < 190, so nothing overflows.
// Logo arm background (the Slint Theme.surface-elevated).
color: theme.surfaceElevated
+3 -1
View File
@@ -159,7 +159,9 @@ Rectangle {
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
// No clip: the child RoundedImage confines itself on both arms, and
// a rectangular scissor never produced this radius. A clip is an
// unconditional batch root, so this one cost a draw call per item.
// Arm 1 — a resolved single cover.
//
// The playlist's own Qobuz graphic (`image_rectangle`) is a
+2 -1
View File
@@ -284,7 +284,8 @@ Item {
Item {
id: mosaic
anchors.fill: parent
clip: true
// No clip: cellOf() tiles the parent rect exactly, and each tile is a
// RoundedImage that scissors itself when it needs to.
visible: root.tiles.length > 0
layer.enabled: !root._noShaders && root.radius > 0 && root.tiles.length > 0
layer.smooth: true
+14 -3
View File
@@ -62,7 +62,8 @@ Item {
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
// No clip: with the wordmark bounded below, nothing in this tile
// can cross its edge.
// Centered artwork "display", nudged up 8px to leave room for
// the wordmark.
@@ -73,7 +74,8 @@ Item {
y: Math.round((parent.height - height) / 2) - 8
radius: 4
color: theme.surfaceCard
clip: true
// No clip: RoundedImage confines itself on both arms; a clip is an
// unconditional batch root, one per visible card.
RoundedImage {
anchors.fill: parent
source: root.artSource
@@ -88,7 +90,16 @@ Item {
font.pixelSize: 15
font.weight: theme.weightBold
font.letterSpacing: 2
x: Math.round((parent.width - width) / 2)
// Bounded + centred-in-box instead of centred-by-width: this
// wordmark is the ONLY thing that could leave the tile (the PT
// string "PRINCIPAIS FAIXAS" at 15px bold with 2px letter
// spacing reaches the 200px edge), and bounding it is what
// lets both of this card's clips go. Worst case is now a right
// ellipsis instead of today's chop at both edges.
width: parent.width - 24
x: 12
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
y: parent.height - height - 16
}
+3 -1
View File
@@ -41,7 +41,9 @@ Rectangle {
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
// No clip: the child RoundedImage confines itself on both arms, and
// a rectangular scissor never produced this radius. A clip is an
// unconditional batch root, so this one cost a draw call per item.
RoundedImage {
anchors.fill: parent
source: card.artPath
+3 -1
View File
@@ -72,7 +72,9 @@ Rectangle {
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
// No clip: the child RoundedImage confines itself on both arms, and
// a rectangular scissor never produced this radius. A clip is an
// unconditional batch root, so this one cost a draw call per item.
RoundedImage {
anchors.fill: parent
source: root.artSource
@@ -41,8 +41,13 @@ Rectangle {
width: genreRow.width
height: btnHeight
radius: 6
// Resting fill translucent under the dynamic background
// (HomeView.slint:89, BrowseHeaderTools.slint:111, PlaylistTagFilter
// .slint:77 — the same chip in three mounts). Accent and hover keep theirs.
color: root.active ? theme.accent
: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
: genreArea.containsMouse
? theme.surfaceHover
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
Row {
id: genreRow
@@ -86,7 +86,12 @@ Rectangle {
: ((cbArea.containsMouse || active) ? "#3dffffff" : "#24ffffff"))
: (primary
? (cbArea.containsMouse && btnEnabled ? theme.accentHover : theme.accent)
: ((cbArea.containsMouse || active) ? theme.surfaceHover : theme.surfaceElevated))
// Resting fill translucent under the dynamic background
// (CircleAction.slint:53-55). The overlay variant above sits on
// artwork and keeps its white scale.
: ((cbArea.containsMouse || active)
? theme.surfaceHover
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)))
border.width: primary ? 0 : 1.5
border.color: overlay ? "#ccffffff" : theme.borderStrong
opacity: btnEnabled ? 1.0 : 0.4
+15 -3
View File
@@ -161,10 +161,21 @@ Rectangle {
width: root.expandable ? (root.open ? root.openWidth : 0) : root.width
height: root.height
radius: (root.searchMode || root.expandable) ? 6 : theme.radiusSm
color: root.elevated ? theme.surfaceElevated : theme.surfaceCard
// Translucent under the app-wide dynamic background, on the SAME
// token the opaque arm uses: surface-card for the expandable search
// overlay (ExpandableSearch.slint:80), surface-elevated for the inline
// toolbar field (BrowseHeaderTools.slint:26).
color: root.elevated
? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard)
border.width: (root.expandable && !root.open) ? 0 : 1
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
clip: true
// Load-bearing ONLY on the expandable arm, where `width` animates
// 0 -> openWidth past a fixed 14px magnifier and the 22/24px clear
// slot. On the plain arm the Row sums to exactly the box and the text
// is guarded one level down, so the scissor there was a free batch
// root on every mounted field.
clip: root.expandable
Behavior on width {
enabled: root.expandable
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
@@ -191,7 +202,8 @@ Rectangle {
- (root.searchMode ? root.glyph + 7 : 0)
- (clearSlot.visible ? clearSlot.width + 7 : 0))
height: parent.height
clip: true
// No clip: TextInput scissors itself (:207) and the
// placeholder elides. A redundant second scissor.
TextInput {
id: input
anchors.fill: parent
+1 -2
View File
@@ -96,8 +96,7 @@ Rectangle {
// `ShellState.app-background-active` (state.slint:4184) — the port spells
// it this way app-wide, npHasTrack included: with nothing playing the
// shell restores its opaque theme surfaces (rule D4).
readonly property bool ambientOn:
ambient && QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: ambient && theme.ambientOn
width: 28
height: 28
+10 -2
View File
@@ -95,8 +95,16 @@ Rectangle {
width: 60
height: 30
radius: 6
color: theme.surfaceElevated
clip: true
// The well itself: surface-elevated @ 0.5 under the dynamic background
// (SegmentedTabBar.slint:108).
color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
// NO CLIP, and that turns a silent crop into a visible one. The invariant
// a caller now owns: `width >= N*segWidth + (N-1)*segSpacing`. All six
// call sites satisfy it today with integer geometry (54/60, 60/60, 90/90);
// a future one that does not will OVERFLOW instead of being cropped
// without a trace, which is the failure you want. The rounded corner comes
// from `segRadius` on the cells — a QML clip is a rectangular scissor and
// never shaped this well.
Row {
anchors.centerIn: parent
+6 -1
View File
@@ -36,7 +36,12 @@ Rectangle {
radius: sm ? 6 : theme.radiusSm
border.width: sm ? 0 : 1
border.color: theme.borderSubtle
color: selArea.containsMouse && enabled ? theme.surfaceHover : theme.surfaceElevated
// Resting fill goes translucent under the dynamic background so the field
// shows through the control (QbzSelect.slint:110-116). Hover keeps its own
// token, which is already translucent.
color: selArea.containsMouse && enabled
? theme.surfaceHover
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
opacity: enabled ? 1.0 : 0.4
// `width`, NOT `menuWidth`: QbzSelect.slint:89-91 resolves the open list as
+7 -2
View File
@@ -27,7 +27,9 @@ Rectangle {
width: segBarRow.width
height: segBarRow.height
color: theme.surfaceElevated
// The well: surface-elevated @ 0.5 under the dynamic background
// (SegmentedTabBar.slint:108).
color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
radius: 6
Row {
@@ -45,7 +47,10 @@ Rectangle {
width: segRow.implicitWidth
height: segRow.implicitHeight
radius: 4
color: active ? theme.surfaceMain
// The ACTIVE cell takes surface-main @ 0.5 — surface-MAIN, not
// elevated, and at the chrome alpha (SegmentedTabBar.slint:29).
color: active
? (theme.ambientOn ? theme.surfaceMainA50 : theme.surfaceMain)
: segArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
@@ -39,8 +39,12 @@ Rectangle {
height: sm ? 30 : 34
radius: 6
opacity: btnEnabled ? 1.0 : 0.35
// Glass under the app-wide dynamic background: the ACTIVE fill goes
// translucent so the moving field shows through (ToggleButton.slint:25-29).
// Only the active arm changes — surface-hover is already translucent and
// the resting arm is transparent, so neither has an alpha to set.
color: root.active
? theme.surfaceHover
? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceHover)
: (tbArea.containsMouse && root.btnEnabled ? theme.surfaceElevated : "transparent")
QbzIcon {
@@ -37,6 +37,12 @@ Item {
property bool compact: false
/// Extra proportional scale on top of `compact` (1.0 = none).
property real scaleFactor: 1.0
/// Cap for the text column, in px. 0 = uncapped, which is what every
/// existing call site gets — this exists so the ONE caller that used to
/// wrap the badge in a `clip: true` (rows/TrackRow.qml's quality cell) can
/// bound the badge instead. A clip there was a batch root per visible row,
/// and Qt cannot merge alpha geometry across clip roots.
property real maxTextWidth: 0
readonly property string tierLabel: tier === "hires" ? "HI-RES"
: (tier === "mp3" ? "MP3"
@@ -110,7 +116,13 @@ Item {
// with `width: parent.width` inside an implicitly-sized Column is
// a binding loop. Both Texts are unwrapped, so `implicitWidth` is
// the natural content width and does not depend on `width`.
width: Math.max(tierText.implicitWidth, detailText.implicitWidth)
// `implicitWidth` stays the natural content width even with the
// elides below (an unwrapped Text does not shrink its implicit
// size), so the no-binding-loop argument above still holds.
width: root.maxTextWidth > 0
? Math.min(root.maxTextWidth,
Math.max(tierText.implicitWidth, detailText.implicitWidth))
: Math.max(tierText.implicitWidth, detailText.implicitWidth)
Text {
id: tierText
@@ -121,6 +133,7 @@ Item {
font.weight: Font.Thin
font.letterSpacing: 0.5
horizontalAlignment: root.bare ? Text.AlignHCenter : Text.AlignLeft
elide: Text.ElideRight
}
Text {
id: detailText
@@ -130,6 +143,7 @@ Item {
font.pixelSize: Math.max(1, Math.round((root.compact ? 8 : 9) * root.sf))
font.weight: Font.Thin
horizontalAlignment: root.bare ? Text.AlignHCenter : Text.AlignLeft
elide: Text.ElideRight
}
}
}
@@ -25,7 +25,9 @@ Rectangle {
width: 68
height: 34
radius: 6
color: theme.surfaceElevated
// The well: surface-elevated @ 0.5 under the dynamic background
// (BrowseHeaderTools.slint:155).
color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
clip: true
Row {
+2 -1
View File
@@ -109,7 +109,8 @@ Rectangle {
// Radius.sm on the album tile, a full circle otherwise.
radius: root.isAlbum ? theme.radiusSm : (root.leadSize / 2)
color: theme.surfaceElevated
clip: root.isAlbum
// No clip: the children are a 20px centred QbzIcon and a
// self-confining RoundedImage. One batch root per row.
QbzIcon {
anchors.centerIn: parent
+129 -46
View File
@@ -8,12 +8,28 @@
// AppShell.slint:358-390. The recovery affordance and logout live in the
// header (offline badge flyout + app menu), like the Slint shell.
//
// The artwork-derived ambient background IS implemented: AmbientField is
// mounted below, `ambientOn` derives from QbzShell.ambientMode, and the
// content frame swaps to surface-main @ 0.22 with a hairline while it runs.
// (The POC-NOTE that used to sit here said the opposite. It was a fossil —
// corrected in passing per LESSONS-2026-08-03 L10, which asks that the ones
// a diff touches be fixed rather than propagated.)
// The artwork-derived ambient background IS implemented, in BOTH of the
// reference's modes (AppShell.slint:206-242): 1 = Ambient, the album-triad
// metaball field (AmbientField) under a tunable dark scrim; 2 = Blurred art,
// the ImmersiveAtmosphere cover look, which carries its own `dim` and takes no
// scrim. `theme.ambientOn` says "a mode is picked and a track is loaded";
// `QbzShell.ambientMode` says WHICH. Until 2026-08-11 mode 2 rendered as mode 1
// (ambient_qt.rs said so out loud), so the two settings looked identical.
//
// THE LAYERING, which is the part that has to be exact. Three tiers, not one:
// chrome (Sidebar / HeaderBar / NPB / queue column / the CONTENT FRAME)
// -> surface-card @ 0.5
// panel (the inset content pane) -> surface-main @ 0.22 + a 1px hairline,
// composited ON TOP OF the frame, because in the reference it is the
// frame's CHILD (AppShell.slint:358-414)
// views -> transparent, with their thin bars at surface-main @ 0.3 and
// their controls at surface-elevated @ 0.5
// The frame tier was missing here: `contentFrame` was a sibling of the shell
// root, so the 8px gutter around the pane showed the RAW field instead of
// card @ 0.5, and the pane composited over the raw field too. Measured on the
// owner's side-by-side: Qt's sidebar (27,28,34) against its gutter (50,52,64) —
// a bright seam at x=240 — where Slint's sidebar (79,72,23) and gutter
// (82,74,20) are the same surface and read as one continuous frame.
import QtQuick
import com.blitzfc.qbz
@@ -30,12 +46,11 @@ Rectangle {
color: theme.surfaceCard
// App-wide dynamic background (phase 14): active when the mode pref is
// on AND a track is loaded (D4: no track -> opaque theme restored).
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// The bottom-most visual layer (AppShell.slint:206-242, declared FIRST
// so every chrome surface paints above it): the ambient album-colored
// field + the dark dim scrim that keeps chrome text legible
// (QBZ_BG_DIM-tunable, default 0.35).
// on AND a track is loaded (D4: no track -> opaque theme restored). The
// predicate itself now lives on the theme object so the ~25 surfaces that
// read it cannot drift apart (they did: the carousel fades and the header
// search field never got it).
readonly property bool ambientOn: theme.ambientOn
// Window-level history navigation: mouse side buttons + two-finger
// horizontal touchpad swipe (#653 parity). Declared FIRST so the whole UI
// hit-tests on top of it — a scroll reaches it only when nothing above
@@ -46,18 +61,45 @@ Rectangle {
anchors.fill: parent
}
// --- The background layers (AppShell.slint:206-242) --------------------
// The bottom-most visual layer, declared before every chrome surface so
// they all paint above it. Mode 1 and mode 2 are DIFFERENT looks and are
// mutually exclusive; neither is mounted while a mode is off or nothing is
// playing, which is the D4 "opaque theme restored" case.
readonly property bool ambientModeOn: root.ambientOn && QbzShell.ambientMode === 1
readonly property bool blurredModeOn: root.ambientOn && QbzShell.ambientMode === 2
// Mode 1 — the album-triad metaball field, plus the tunable dark scrim
// that keeps chrome text legible over a bright album palette
// (QBZ_BG_DIM, default 0.35). The scrim is mode 1 ONLY: mode 2's
// atmosphere applies the same dim internally (AppShell.slint:236).
AmbientField {
anchors.fill: parent
visible: root.ambientOn
running: root.ambientOn
visible: root.ambientModeOn
running: root.ambientModeOn
}
Rectangle {
anchors.fill: parent
visible: root.ambientOn
visible: root.ambientModeOn
color: "#000000"
opacity: QbzShell.ambientDim
}
// Mode 2 — Blurred art: the SAME ImmersiveAtmosphere the immersive view
// and the album/artist headers use, at window size (AppShell.slint:221-231
// reuses the identical component). `animated` follows the transport, so a
// paused player holds the static pose instead of drifting forever, and the
// fallback is the plain cover for a track whose atmosphere bitmap has not
// been generated yet.
ImmersiveAtmosphere {
anchors.fill: parent
visible: root.blurredModeOn
source: root.blurredModeOn ? QbzImmersive.atmosphereUrl : ""
fallbackSource: root.blurredModeOn ? QbzPlayer.npArtworkPath : ""
animated: root.blurredModeOn && QbzPlayer.npPlaying
dim: QbzShell.ambientDim
}
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
property var hostWindow: null
@@ -233,45 +275,77 @@ Rectangle {
}
}
// Content frame — the rounded, inset surface-main panel (Radius.md,
// 8px gaps left/right/bottom, flush to the header).
// Content FRAME — the chrome slab the content pane is inset INTO
// (AppShell.slint:357-362). It is the same tier as the sidebar, the header
// and the player bar, and under the dynamic background it takes the same
// surface-card @ 0.5: the 8px gutter it leaves around the pane is the
// "bezel", and it must read as one continuous frame with the sidebar, not
// as a bright band of raw background between them.
//
// It also has to be the pane's PARENT, not its sibling. In the reference
// the pane is a child of this rectangle, so surface-main @ 0.22 composites
// over card @ 0.5 over the field; as a sibling it composited over the raw
// field and the pane came out roughly 1.8x brighter than the reference's.
// Measured at y=700 on the owner's capture, Slint's pane is exactly
// 0.78 x its gutter (51,48,34 against 65,62,44) — that ratio only falls out
// if the gutter is already under the pane.
//
// With the background OFF this is surface-card, i.e. the same colour the
// shell root paints, so the geometry and the look are byte-identical to
// what shipped before.
Rectangle {
id: contentFrame
anchors.left: sidebar.right
anchors.right: queueColumn.left
anchors.top: header.bottom
anchors.bottom: npb.top
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
// Content PANE — the rounded, inset surface-main panel (Radius.md,
// 8px gaps left/right/bottom, flush to the header: the reference gives
// the frame no top padding so the pane butts the header band).
Rectangle {
id: contentPane
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.bottomMargin: 8
radius: theme.radiusMd
// Frosted content panel while the ambient background is active
// (AppShell.slint: surface-main @ 0.22 + 1px #ffffff@0.10 hairline).
// (AppShell.slint: surface-main @ 0.22 + 1px #ffffff@0.10
// hairline), over the frame's card @ 0.5.
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
border.width: root.ambientOn ? 1 : 0
border.color: theme.frostBorder
clip: true
// QBZ_PANE_LAYER: collapse the pane into one cached texture instead
// of redrawing its whole (all-alpha, unbatchable) draw list on every
// whole-window repaint. Measurement knob, default off — see
// settings_qt::pane_layer.
layer.enabled: QbzShell.paneLayer
// The view-mount chain — every route arm, every hard-won comment and
// the MyQBZ `kind` discriminator now live in ContentRouter.qml, which
// the KIOSK shell mounts too (kiosk-port contract §4.2 / D3). The
// Slint kiosk copies AppShell's mount block verbatim and calls that
// copy debt in its own header (KioskShell.slint:10-17); this is the
// named fix, so the two shells cannot drift.
// The view-mount chain — every route arm, every hard-won comment
// and the MyQBZ `kind` discriminator now live in ContentRouter.qml,
// which the KIOSK shell mounts too (kiosk-port contract §4.2 / D3).
// The Slint kiosk copies AppShell's mount block verbatim and calls
// that copy debt in its own header (KioskShell.slint:10-17); this
// is the named fix, so the two shells cannot drift.
//
// `kiosk` defaults false, i.e. this instance routes every id to the
// desktop view it always did. Reached from elsewhere in this document
// through `contentRouter.currentItem` (the multi-select seam above) —
// a QML `id` does not cross documents, so `viewLoader` is no longer
// nameable here and that public property is the replacement.
// desktop view it always did. Reached from elsewhere in this
// document through `contentRouter.currentItem` (the multi-select
// seam above) — a QML `id` does not cross documents, so
// `viewLoader` is no longer nameable here and that public property
// is the replacement.
ContentRouter {
id: contentRouter
anchors.fill: parent
}
}
}
// --- Bezel corners ----------------------------------------------------
// `clip: true` on contentFrame is a RECTANGULAR scissor in Qt Quick — it
// `clip: true` on contentPane is a RECTANGULAR scissor in Qt Quick — it
// is a glScissor / QPainter setClipRect and it does NOT follow `radius`.
// The radius only shapes that Rectangle's own fill, so anything that
// paints opaquely at the frame's corners paints straight over the bezel
@@ -287,7 +361,7 @@ Rectangle {
// reaches the top edge. The mask therefore belongs here, once, and it
// covers every present and future view.
//
// SIBLINGS of contentFrame, not children of it. Inside the frame they sat
// SIBLINGS of the pane, not children of it. Inside it they sat
// at z:0 with the view Loader, so any overlay a VIEW mounts painted over
// them and the corners read square while it was open — the pane-filling
// modals are exactly that: DiscoverConfigModal z:3100 (HomeView:1130),
@@ -326,14 +400,16 @@ Rectangle {
// an FBO the size of the whole content pane, re-rendered on every window
// resize and invalidated by anything animating inside the pane.
//
// The fill is the shell base (`root.color`) because that is literally
// what shows through the bezel: the 8px gutter, the HeaderBar above
// and the NPB gap below are all surface-card, and nothing else paints
// between `root` and the frame while the corners are on.
// The fill is `contentFrame.color` because that is literally what shows
// through the bezel — the frame IS the 8px gutter, and with the background
// off it is surface-card, the same colour as the HeaderBar above and the
// NPB gap below. (It was `root.color` before the frame existed; identical
// value, but now it tracks the surface it is actually cutting into rather
// than one that happens to match.)
//
// Gated on the ambient background being OFF, and that is not a
// shortcut: with the dynamic background active the frame goes
// translucent (surface-main @ 0.22 + hairline) and the ambient field
// shortcut: with the dynamic background active the pane goes
// translucent (surface-main @ 0.22 + hairline) and the field
// is SUPPOSED to show through the corners, so an opaque nub would be
// the regression. The same flag also suppresses the album/artist
// atmosphere (AlbumView.qml:71 `headerAtmoOn = pref && !ambientOn`,
@@ -354,20 +430,27 @@ Rectangle {
// parented to the window Overlay, where covering the pane corners is the
// correct behaviour. A future full-bleed opaque pane child must round
// itself the same way — there is no mask that can do it for it here.
BezelCorner { corner: 0; fill: root.color; r: contentFrame.radius
// Positions are the PANE's rect in shell coordinates. The pane is a child
// of the frame now, so its own x/y are frame-local (8, 0) and have to be
// offset by the frame's origin — reading contentPane.x directly here would
// put every nub 8px to the left of the corner it is meant to cut.
readonly property real _paneX: contentFrame.x + contentPane.x
readonly property real _paneY: contentFrame.y + contentPane.y
BezelCorner { corner: 0; fill: contentFrame.color; r: contentPane.radius
visible: !root.ambientOn
x: contentFrame.x; y: contentFrame.y }
BezelCorner { corner: 1; fill: root.color; r: contentFrame.radius
x: root._paneX; y: root._paneY }
BezelCorner { corner: 1; fill: contentFrame.color; r: contentPane.radius
visible: !root.ambientOn
x: contentFrame.x + contentFrame.width - width; y: contentFrame.y }
BezelCorner { corner: 2; fill: root.color; r: contentFrame.radius
x: root._paneX + contentPane.width - width; y: root._paneY }
BezelCorner { corner: 2; fill: contentFrame.color; r: contentPane.radius
visible: !root.ambientOn
x: contentFrame.x + contentFrame.width - width
y: contentFrame.y + contentFrame.height - height }
BezelCorner { corner: 3; fill: root.color; r: contentFrame.radius
x: root._paneX + contentPane.width - width
y: root._paneY + contentPane.height - height }
BezelCorner { corner: 3; fill: contentFrame.color; r: contentPane.radius
visible: !root.ambientOn
x: contentFrame.x
y: contentFrame.y + contentFrame.height - height }
x: root._paneX
y: root._paneY + contentPane.height - height }
// The bezel nub itself. Kept as an inline component (no outer `id` is
// referenced inside it — inline components do not share the document's
+6 -2
View File
@@ -61,7 +61,7 @@ Rectangle {
// surface-card @ 0.5 while the ambient background is active (phase 14,
// HeaderBar.slint's with-alpha(app-background-surface-alpha)).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// The host ApplicationWindow (custom chrome); null in previews.
@@ -687,7 +687,11 @@ Rectangle {
radius: 6
border.width: 1
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
color: theme.surfaceElevated
// surface-elevated @ 0.5 under the dynamic background
// (HeaderBar.slint:728) — it sits ON the already-translucent header, so
// leaving it opaque left one solid slab in the middle of a bar the
// field is otherwise showing through.
color: root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
QbzIcon {
name: "search"
+12 -2
View File
@@ -219,8 +219,13 @@ Rectangle {
radius: theme.radiusSm
color: (backArea.containsMouse && QbzShell.canBack)
? root.chromeHoverBg : "transparent"
opacity: QbzShell.canBack ? 1.0 : 0.32
QbzIcon {
// Opacity on the LEAF, not on the button: the
// Rectangle resolves to "transparent" in every
// disabled frame (hover requires canBack), so the
// group node only ever dimmed this glyph — and an
// always-on opacity group is its own batch.
opacity: QbzShell.canBack ? 1.0 : 0.32
name: "chevron-left"
width: 22
height: 22
@@ -245,8 +250,13 @@ Rectangle {
radius: theme.radiusSm
color: (fwdArea.containsMouse && QbzShell.canForward)
? root.chromeHoverBg : "transparent"
opacity: QbzShell.canForward ? 1.0 : 0.32
QbzIcon {
// Opacity on the LEAF, not on the button: the
// Rectangle resolves to "transparent" in every
// disabled frame (hover requires canForward), so the
// group node only ever dimmed this glyph — and an
// always-on opacity group is its own batch.
opacity: QbzShell.canForward ? 1.0 : 0.32
name: "chevron-right"
width: 22
height: 22
+5 -1
View File
@@ -28,7 +28,11 @@ import "../theme"
Rectangle {
id: root
color: "transparent"
clip: true
// Clipped ONLY when stacked with the other panel. The drag ghost really
// does leave this rect, but in the single-panel case the panel coincides
// exactly with queueColumn, which already clips (AppShell.qml) — so the
// scissor is pure duplication there, and duplication costs a batch root.
clip: QbzShell.queueOpen && QbzShell.lyricsOpen
QbzTheme { id: theme }
+21 -4
View File
@@ -43,7 +43,7 @@ Rectangle {
// surface-card @ 0.5 while the ambient background is active (phase 14,
// PlayerBarSmall.slint).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
QbzTheme { id: theme }
@@ -212,8 +212,13 @@ Rectangle {
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
height: parent.height
radius: 2
color: theme.textMuted
opacity: 0.35
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
theme.textMuted.b, 0.35)
// Alpha in the material, not a container `opacity`: an
// always-on opacity node pins this quad in its own batch,
// and the three seek quads use compatible materials
// (QSGSmoothColorMaterial::compare() returns 0), so folding
// it lets rail + cache + progress merge.
}
// Playback progress line.
Rectangle {
@@ -263,7 +268,9 @@ Rectangle {
anchors.top: parent.top
anchors.bottom: parent.bottom
width: (root.width - 6) * root.sideFrac
clip: true
// No clip: the Row sums to exactly this column (width-59 + 19
// + 40, spacing 0) and the two time Texts above are now
// bounded, which was the only escape path.
Row {
anchors.fill: parent
@@ -318,12 +325,22 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
// Bounded so the column below needs no scissor: a
// NaN transient or a >16h duration is the only way
// these overflow, and truncating here lands at the
// same x the clip did.
width: parent.width
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
text: root.fmt(QbzPlayer.npElapsedSecs)
font.family: "monospace"
font.pixelSize: 10
color: theme.textMuted
}
Text {
width: parent.width
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
text: root.fmt(QbzPlayer.npDurationSecs)
font.family: "monospace"
font.pixelSize: 10
+8 -3
View File
@@ -70,7 +70,7 @@ import "../theme"
Rectangle {
id: root
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
readonly property bool isClassic: QbzShell.npbMode === 1
@@ -312,8 +312,13 @@ Rectangle {
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
height: parent.height
radius: 2
color: theme.textMuted
opacity: 0.35
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
theme.textMuted.b, 0.35)
// Alpha in the material, not a container `opacity`: an
// always-on opacity node pins this quad in its own batch,
// and the three seek quads use compatible materials
// (QSGSmoothColorMaterial::compare() returns 0), so folding
// it lets rail + cache + progress merge.
}
Rectangle {
width: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
+15 -6
View File
@@ -26,7 +26,11 @@ import "../theme"
Rectangle {
id: root
color: "transparent"
clip: true
// Clipped ONLY when stacked with the other panel. The drag ghost really
// does leave this rect, but in the single-panel case the panel coincides
// exactly with queueColumn, which already clips (AppShell.qml) — so the
// scissor is pure duplication there, and duplication costs a batch root.
clip: QbzShell.queueOpen && QbzShell.lyricsOpen
// Queue (0) / History (1) — Slint QueueState.tab.
property int tab: 0
@@ -378,7 +382,8 @@ Rectangle {
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
// No clip: RoundedImage self-confines. One batch root per
// visible queue row.
RoundedImage {
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
@@ -816,7 +821,7 @@ Rectangle {
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceCard
clip: true
// No clip: RoundedImage self-confines.
RoundedImage {
anchors.fill: parent
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
@@ -1374,10 +1379,14 @@ Rectangle {
width: root.width - 2 * theme.spacingMd
height: 34
radius: theme.radiusSm
color: theme.surfaceElevated
color: Qt.rgba(theme.surfaceElevated.r, theme.surfaceElevated.g,
theme.surfaceElevated.b, 0.95)
border.width: 1
border.color: theme.accent
opacity: 0.95
border.color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.95)
// Alpha folded into the fill/border instead of a container `opacity`:
// an opacity node is its own batch group, and this ghost is live for
// the whole drag. Every theme builds these tokens opaque (checked
// across crates/qbz-theme/src), so this is the same pixel.
Text {
anchors.fill: parent
anchors.leftMargin: theme.spacingSm
+1 -1
View File
@@ -309,7 +309,7 @@ Rectangle {
: theme.sidebarOpenWidth
// surface-card @ 0.5 while the ambient background is active (phase 14).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// Square edges; clip keeps content from spilling while the width
// animates (same as the Slint root).
@@ -108,8 +108,14 @@ Item {
width: root.width
height: root.width
radius: theme.radiusMd
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
clip: true
// The art WELL takes surface-main @ 0.5, the chrome tier — not the
// content pane's 0.22 (SidebarNowPlayingDock.slint:193). It shows
// only in the frame around a cover that has not loaded, so at 0.22
// it read as a hole in the sidebar rather than a recess in it.
color: root.ambientOn ? theme.surfaceMainA50 : theme.surfaceMain
// No clip: RoundedImage confines itself on both arms; a clip is an
// unconditional batch root. The 28x28 eye chip and the centred icon
// are strictly inside the square.
RoundedImage {
visible: QbzPlayer.npHasTrack
+3 -2
View File
@@ -49,7 +49,7 @@ Rectangle {
/// Title clicked — the host opens the Track Info modal.
signal trackInfoRequested()
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// Track Info is a Qobuz-only surface (Slint gates it on
// NowPlayingState.source == "qobuz"). The Qt port publishes no np_source,
// so a numeric track id is the proxy — local / Plex / ephemeral rows do
@@ -120,7 +120,8 @@ Rectangle {
color: theme.surfaceElevated
border.width: root.artBorderWidth
border.color: root.artBorderColor
clip: true
// No clip: RoundedImage confines itself on both arms; a clip is an
// unconditional batch root, and this one is always on screen.
RoundedImage {
visible: QbzPlayer.npHasTrack
anchors.fill: parent
+24 -1
View File
@@ -34,7 +34,8 @@ QtObject {
"borderSubtle": "#14ffffff", "borderMuted": "#38ffffff",
"borderStrong": "#59ffffff", "focusRing": "#4285f4",
"favorite": "#ef4444", "cardShadow": "#66000000",
"surfaceCardA50": "#801a1a1a", "surfaceMainA22": "#380f0f0f",
"surfaceCardA50": "#801a1a1a", "surfaceElevatedA50": "#80252525",
"surfaceMainA50": "#800f0f0f", "surfaceMainA22": "#380f0f0f",
"surfaceMainA30": "#4d0f0f0f", "frostBorder": "#1affffff",
"isDark": true
})
@@ -103,11 +104,33 @@ QtObject {
// thin bars surface-main @30%, hairline = alpha tier 10%. The live
// values for the QBZ_BG_* env knobs still ride QbzBridge
// .ambientSurfaceAlpha / .ambientBarAlpha.
//
// The 50% tier exists on THREE tokens because the Slint picks the base by
// the surface's tier, not by one blanket colour: chrome takes surface-card
// (Sidebar.slint:693, HeaderBar.slint:561, PlayerBar.slint:130,
// AppShell.slint:360), controls sitting on a panel take surface-elevated
// (ToggleButton.slint:25, QbzSelect.slint:112, SegmentedTabBar.slint:108,
// CircleAction.slint:53, HeaderBar.slint:728) and the Large dock's art well
// takes surface-main (SidebarNowPlayingDock.slint:193).
readonly property color surfaceCardA50: _c("surfaceCardA50")
readonly property color surfaceElevatedA50: _c("surfaceElevatedA50")
readonly property color surfaceMainA50: _c("surfaceMainA50")
readonly property color surfaceMainA22: _c("surfaceMainA22")
readonly property color surfaceMainA30: _c("surfaceMainA30")
readonly property color frostBorder: _c("frostBorder")
// THE ONE ambient predicate. Every chrome/control that goes translucent
// under the app-wide dynamic background reads this instead of re-deriving
// `QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack` (which the port had
// copied into 25 files, and which is exactly the kind of duplicated
// expression that lets one surface drift out of the set — the fade
// rectangles and the header search field were both missing it).
// Mirrors ShellState.app-background-active (state.slint:4184) minus the
// renderer-tier arm: the Qt field renders on the software path too
// (ShaderEffect where shaders exist, Canvas where they do not), so the
// feature is never taken away from a weak GPU here.
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// --- ON AN ACCENT FILL: the glyph tint and its colour twin -----------
//
// AN OWNER-APPROVED DEVIATION FROM SLINT. Read this before touching a
+3 -1
View File
@@ -152,7 +152,9 @@ Rectangle {
anchors.centerIn: parent
radius: 4
color: theme.surfaceElevated
clip: true
// No clip: RoundedImage confines its own crop on both arms.
// One batch root per list row, for a scissor that never
// rounded anything.
RoundedImage {
anchors.fill: parent
source: root.artSource !== "" ? root.artSource : (root.item.artPath || "")
+4 -2
View File
@@ -33,7 +33,7 @@ Rectangle {
// Transparent while the ambient background is active (phase 14 —
// HomeView.slint:163: the frosted content panel shows through).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
@@ -583,7 +583,9 @@ Rectangle {
height: 224
radius: 12
color: theme.surfaceElevated
clip: true
// No clip: RoundedImage confines itself on both arms; a clip is an
// unconditional batch root. coverMenu and CoverLightbox are
// view-root siblings, never descendants of this frame.
RoundedImage {
anchors.fill: parent
// A custom cover override (shared custom_artwork
@@ -57,7 +57,7 @@ Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
+12 -3
View File
@@ -45,7 +45,7 @@ Rectangle {
// Transparent while the ambient background is active (phase 14 —
// HomeView.slint:163: the frosted content panel shows through).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
@@ -2167,7 +2167,12 @@ Rectangle {
id: jumpBar
width: parent.width - 64
padH: 0
barBg: root.ambientOn ? "transparent" : theme.surfaceMain
// surface-main @ bar-alpha (0.3) under the dynamic
// background, NOT transparent: the sticky bar overlays the
// page content it scrolls over, so it needs SOME fill to
// stay readable — the reference gives thin bars their own
// lighter tier for exactly that (ArtistPageView.slint:1108).
barBg: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
tabs: root.jumpTabs
activeTabId: root.activeJumpTab
onTabClicked: function (id) { root.scrollToSection(id) }
@@ -2684,7 +2689,11 @@ Rectangle {
height: Math.max(0, root.height - y)
width: root.networkOpen ? 300 : 0
clip: true
color: theme.surfaceCard
// Chrome tier: surface-card @ 0.5 under the dynamic background
// (ArtistPageView.slint:1196). Its 44px header row stays TRANSPARENT
// there (:1221) — it sits ON this already-translucent body, and a
// second translucent layer would compound to near-opaque.
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.InOutQuad } }
Rectangle { anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 1; color: theme.borderSubtle }
@@ -55,7 +55,7 @@ Rectangle {
// Transparent while the ambient background is active — the frosted content
// panel shows through (HomeView.qml:53 and its twelve siblings).
color: root.ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (Radius.md): QML clips are
// rectangular, so the frame's rounding never reaches the view — the view's
@@ -31,7 +31,7 @@ Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
+19 -3
View File
@@ -58,7 +58,7 @@ Rectangle {
// Transparent while the ambient background is active (phase 14 —
// HomeView.slint:163: the frosted content panel shows through).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
@@ -423,13 +423,17 @@ Rectangle {
}
}
}
// Cider-style edge fades (Carousel.slint): content dissolves
// into the page background at the scrolled edges.
// Soft edge fades (Carousel.slint): content dissolves
// into the page background at the scrolled edges. Hidden under the
// app-wide dynamic background — they fade to the OPAQUE
// surface-main, so over a moving field they read as two dark slabs
// rather than a dissolve (Carousel.slint:304, :312).
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
visible: !theme.ambientOn
opacity: rail.contentX > 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
@@ -443,6 +447,7 @@ Rectangle {
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
visible: !theme.ambientOn
opacity: rail.contentX < maxScroll - 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
@@ -1254,6 +1259,17 @@ Rectangle {
width: parent.width
height: 56
// Thin-bar tier: surface-main @ bar-alpha (0.3) under the app-wide
// dynamic background, opaque surface-main otherwise (HomeView.slint:182).
// The toolbar had NO fill of its own — with the background off the
// view root's surface-main showed through and it looked right, but
// the view root goes transparent under the background, so the bar
// lost its backing exactly when it needed one.
Rectangle {
anchors.fill: parent
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
Row {
// Slint left-controls: x 32 + NavButtons (now a 0px
// placeholder) + 16px spacing -> the pill starts at 48.
@@ -32,7 +32,7 @@ Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
+4 -2
View File
@@ -48,7 +48,7 @@ Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
@@ -470,7 +470,9 @@ Rectangle {
width: parent.width - 64
padH: 0
showSearch: false
barBg: root.ambientOn ? "transparent" : theme.surfaceMain
// surface-main @ bar-alpha (0.3), LabelPageView.slint:593 —
// see the note on the identical mount in ArtistView.
barBg: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
tabs: root.jumpTabs
activeTabId: root.activeJumpTab
onTabClicked: function (id) { root.scrollToSection(id) }
+1 -1
View File
@@ -54,7 +54,7 @@ Rectangle {
// Transparent while the ambient background is active (phase 14 —
// HomeView.slint:163: the frosted content panel shows through).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
+1 -1
View File
@@ -30,7 +30,7 @@ Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
+1 -1
View File
@@ -52,7 +52,7 @@ Rectangle {
// Transparent while the ambient background is active (the frosted
// content panel shows through — AppShell's contentFrame owns the fill).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (QML clips are rectangular).
radius: 12
+1 -1
View File
@@ -61,7 +61,7 @@ Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
+1 -1
View File
@@ -47,7 +47,7 @@ Rectangle {
readonly property bool mostPlayed: mode === "mostPlayed"
readonly property var items: doc.items || []
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// RecentAlbumsView.slint:31 is ambient-aware; MostPlayedAlbumsView
// .slint:26 is plain surface-main.
color: (!mostPlayed && ambientOn) ? "transparent" : theme.surfaceMain
@@ -35,7 +35,7 @@ Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
@@ -56,7 +56,7 @@ Rectangle {
// round (the literal in all 14 painting view roots).
radius: 12
color: root.ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
QbzTheme { id: theme }
+6 -2
View File
@@ -52,7 +52,7 @@ import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
@@ -689,7 +689,11 @@ Rectangle {
height: 34
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: sortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
// PlaylistView.slint:855-859 — resting fill
// translucent under the dynamic background.
color: sortArea.containsMouse
? theme.surfaceHover
: (root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
border.width: 1
border.color: theme.borderSubtle
Row {
+13 -1
View File
@@ -57,7 +57,7 @@ import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
@@ -398,6 +398,18 @@ Rectangle {
Item {
width: parent.width
height: 56
// Thin-bar tier: surface-main @ bar-alpha (0.3) under the app-wide
// dynamic background, opaque surface-main otherwise (SearchResultsView.slint:386).
// The toolbar had NO fill of its own — with the background off the
// view root's surface-main showed through and it looked right, but
// the view root goes transparent under the background, so the bar
// lost its backing exactly when it needed one.
Rectangle {
anchors.fill: parent
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
Text {
x: 32
anchors.verticalCenter: parent.verticalCenter
+12 -1
View File
@@ -1,6 +1,6 @@
// Horizontal album rail (Carousel.slint) — extracted for reuse by the
// detail views (AlbumView / ArtistView): section header + page chevrons +
// clipped ListView of the shared AlbumCard, with Cider-style edge fades.
// clipped ListView of the shared AlbumCard, with soft edge fades.
import QtQuick
import com.blitzfc.qbz
@@ -76,11 +76,21 @@ Column {
artworkUrl: modelData.artUrl || ""
}
}
// Edge fades — content dissolves into the page background at the
// scrolled edges instead of a hard cut. They fade to the OPAQUE
// surface-main, so under the app-wide dynamic background they would
// paint two dark 56px slabs over the moving field: the reference hides
// them outright for exactly that reason (Carousel.slint:304 and :312,
// `visible: !ShellState.app-background-active`). This is what put the
// black band down the right edge of "From the same artist" in the
// owner's 2026-08-04 capture, where the Slint shows the field through
// the half-scrolled card.
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
visible: !theme.ambientOn
opacity: rail.contentX > 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
@@ -94,6 +104,7 @@ Column {
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
visible: !theme.ambientOn
opacity: rail.contentX < root.maxScroll - 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
@@ -96,7 +96,7 @@ Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
@@ -78,7 +78,7 @@ Rectangle {
readonly property string gridId: root.isMix ? "mixtapes" : "collections"
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool ambientOn: theme.ambientOn
// A LITERAL 12, as in all 14 painting view roots: QML clips are
// RECTANGULAR, so the view must round its own fill or the shell bezel
// reads square (AppShell.qml:188-258).
+30 -12
View File
@@ -1,4 +1,4 @@
//! App-wide dynamic ambient background ("modo Cider", phase 14) — the
//! App-wide dynamic ambient background (phase 14) — the
//! palette half of the feature. Ports the album-art triad extraction the
//! Slint pushes to `shader_underlay::set_palette` on every track change
//! (crates/qbz/src/playback.rs:1590-1643 → crates/qbz/src/immersive.rs):
@@ -17,17 +17,35 @@
//! Slint drive point. Decode + downscale run on a spawned task; only the
//! three hex strings cross to the UI thread.
//!
//! POC-NOTEs:
//! - The Slint renders the ambient scene with a custom WGSL shader (four
//! domain-warped album-colored metaballs + grain + audio breathe). The
//! POC renders the same orbit model as additive canvas gradients
//! (qml/AmbientField.qml): no fbm warp, no true metaball fusion, no
//! grain, no level_smooth breathe (the POC has no FFT tap), so the field
//! is a close-but-simpler read of the same concept.
//! - The "blurred art" mode (route A, ImmersiveAtmosphere) is NOT
//! implemented; a persisted "blurred" pref maps to the ambient look.
//! - The Slint gates the whole feature to the wgpu renderer tier; the
//! POC's canvas path is renderer-agnostic, so it is always available.
//! The three POC-NOTEs that used to sit here are gone because the gaps they
//! described are closed (2026-08-11) — leaving them would have kept asserting
//! defects the code no longer has:
//! - The scene is now a real shader. `qml/assets/shaders/ambient.frag` is a
//! line-for-line GLSL port of the WGSL (fbm domain warp, r^2/d^2 metaball
//! fusion, the smoothstep iso-surface, the saturation push, the grain), and
//! `AmbientField.qml` keeps the additive-gradient Canvas only as the
//! software-scene-graph fallback. The old note called that Canvas "a
//! close-but-simpler read"; it was closer to a different picture, because it
//! painted over a BLACK base and the reference has a 42%-of-album-colour
//! floor everywhere.
//! - "Blurred art" is a real, separate mode. `app_background_mode()` returns
//! the reference's 0/1/2 and AppShell mounts ImmersiveAtmosphere for 2, the
//! same component the reference reuses; the pref no longer collapses to the
//! ambient look.
//!
//! What REMAINS a deliberate divergence:
//! - The Slint gates the whole feature to the wgpu renderer tier (its scene is
//! the wgpu underlay, so off-tier there is nothing to show). Here the field
//! picks a ShaderEffect or a Canvas from `GraphicsInfo`, so it renders on
//! every path and the feature is never taken away from a weak GPU.
//! - No audio breathe: the reference multiplies the blob radius by
//! `1 + 0.12 * level_smooth`, which needs the 30 fps FFT drain. Slint runs
//! that drain for mode 1 regardless — the same drain renders its shader
//! texture — while this field is timer-driven, so the tap would be switched
//! on app-wide purely for the pulse. At level 0 the reference's breathe is
//! exactly 1.0, so the geometry is unchanged; only the audio reaction is
//! absent. The shader keeps the `levelSmooth` uniform so wiring it later is
//! a QML binding, not a shader edit.
use cxx_qt_lib::QString;
+8 -1
View File
@@ -2113,7 +2113,14 @@ pub(crate) fn toggle_system_title_bar() {
/// ambient layer is pure QML — no restart needed).
pub(crate) fn toggle_ambient_background() {
let mode = settings_qt::toggle_ambient_background();
log::info!("[qbz-qt] app_background -> {}", if mode == 1 { "ambient" } else { "off" });
log::info!(
"[qbz-qt] app_background -> {}",
match mode {
1 => "ambient",
2 => "blurred",
_ => "off",
}
);
shell_bridge::ui(move |mut b| b.as_mut().set_ambient_mode(mode));
}
+1 -1
View File
@@ -9,7 +9,7 @@
//! | consumer | line | meaning |
//! |---|---|---|
//! | `shader_scenes_available` | `:8557` | the 6 immersive shader scenes — they render BLACK off the GPU tier |
//! | `app_background_available` | `:8563` | the app-wide dynamic background ("modo Cider") |
//! | `app_background_available` | `:8563` | the app-wide dynamic background |
//! | `reduce_motion` | `:8597` | `kiosk_profile \|\| !use_gpu_renderer` |
//!
//! This port had none of it. `reduce_motion` shipped as the kiosk half alone
+14
View File
@@ -212,8 +212,20 @@ struct ThemeTokens {
// Ambient-layer derivations (phase-14 tokens, now theme-derived):
// chrome surface-card @50%, frosted panel surface-main @22%, thin bars
// surface-main @30%, hairline = alpha tier 10%.
//
// The two @50% siblings below carry the SAME `app-background-surface-alpha`
// (0.5) the chrome does — the Slint applies it to three different tokens
// depending on the surface's tier: surface-card for chrome
// (Sidebar/HeaderBar/PlayerBar/the content frame), surface-elevated for
// controls sitting ON a panel (ToggleButton/QbzSelect/SegmentedTabBar/
// CircleAction/the header search field), and surface-main for the Large
// dock's art well (SidebarNowPlayingDock.slint:193).
#[serde(rename = "surfaceCardA50")]
surface_card_a50: String,
#[serde(rename = "surfaceElevatedA50")]
surface_elevated_a50: String,
#[serde(rename = "surfaceMainA50")]
surface_main_a50: String,
#[serde(rename = "surfaceMainA22")]
surface_main_a22: String,
#[serde(rename = "surfaceMainA30")]
@@ -261,6 +273,8 @@ fn tokens_for(colors: &ThemeColors) -> ThemeTokens {
card_shadow: argb(colors.card_shadow),
alpha: colors.alpha.iter().map(|c| argb(*c)).collect(),
surface_card_a50: with_alpha(colors.surface_card, 0x80),
surface_elevated_a50: with_alpha(colors.surface_elevated, 0x80),
surface_main_a50: with_alpha(colors.surface_main, 0x80),
surface_main_a22: with_alpha(colors.surface_main, 0x38),
surface_main_a30: with_alpha(colors.surface_main, 0x4d),
frost_border: argb(colors.alpha[ALPHA_PCTS.iter().position(|p| *p == 10).unwrap_or(4)]),
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Count Qt Quick scene-graph BATCHES for a scene you have driven to by hand.
#
# WHY THIS EXISTS. GPU utilisation is a terrible optimisation target: it moved by
# nothing across four different hypotheses (shader resolution, pane layering,
# animation tick, duplicated clocks) because none of them touched the thing that
# actually costs. QSG_RENDER_TIMING then showed render=2ms / sync=0 / polish=0 —
# the scene is cheap to COMPUTE — and QSG_RENDERER_DEBUG=render showed
# "Alpha: 533 nodes in 449 batches", i.e. 1.2 nodes per batch and ~28k draw calls
# a second. Batch count is the number that moves when the tree improves, and it
# is deterministic: same view, same batches. No thermal ramp, no measurement
# window to argue about, no 20% spread between consecutive readings.
#
# YOU DRIVE, THEN IT SAMPLES. The first cut of this script launched the app and
# killed it after a fixed timeout, which meant it sampled whatever was on screen
# during startup — and since the dynamic background only mounts once a track is
# loaded, the "reference scene" it measured was the one with the background OFF.
# It now waits for you: get to the view, start playback, let it settle, press
# Enter, and only then does the sample window open.
#
# Usage:
# ./scripts/qt-batches.sh # launch, you drive, Enter, 8s sample
# SECS=15 ./scripts/qt-batches.sh # longer sample window
# KEEP=1 ./scripts/qt-batches.sh # leave the app running afterwards
# ATLAS_OVERLAY=1 ./scripts/qt-batches.sh # tint atlased textures (diagnostic)
# ATLAS=8192 ./scripts/qt-batches.sh # force a bigger texture atlas
# BIN=crates/target/debug/qbz ./scripts/qt-batches.sh
#
# THE REFERENCE SCENE, so two runs are comparable: album page, NPB Large, the
# spectrum band up (the eye toggle on), a track PLAYING, dynamic background on.
# The count is per-scene — comparing Home against an album page tells you
# nothing, and comparing background-on against background-off tells you only
# that translucency moved the tree into the alpha pass.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
BIN="${BIN:-crates/target/release/qbz}"
SECS="${SECS:-8}"
LOG="$(mktemp "${TMPDIR:-/tmp}/qbz-batches-XXXXXX.log")"
[[ -x "$BIN" ]] || { echo "no binary at $BIN — build first" >&2; exit 1; }
# ~1.2 MB/s of renderer chatter, so a long drive makes a big file. Harmless, but
# say so rather than let it be a surprise.
# ATLAS_OVERLAY=1 tints every texture that made it INTO the shared atlas.
# Untinted covers mean the atlas filled up: there is one Atlas instance, and
# once allocate() fails each subsequent image becomes a standalone texture with
# a unique comparisonKey() and is permanently unmergeable. That is a bigger
# lever than any clip, so it is worth ruling in or out before reading a delta.
# It is NOT on by default: the tint makes the UI unusable to look at, and this
# script's main job is the batch count.
atlas_env=()
if [[ "${ATLAS_OVERLAY:-0}" == 1 ]]; then
atlas_env+=(QSG_ATLAS_OVERLAY=1)
echo "[batches] ATLAS OVERLAY ON — tinted = in the atlas, untinted = its own texture"
fi
# ATLAS=8192 raises the atlas past the window-derived default, the actual fix
# if the overlay shows overflow.
if [[ -n "${ATLAS:-}" ]]; then
atlas_env+=(QSG_ATLAS_WIDTH="$ATLAS" QSG_ATLAS_HEIGHT="$ATLAS")
echo "[batches] atlas forced to ${ATLAS}x${ATLAS}"
fi
echo "[batches] launching $BIN (renderer debug is ~1 MB/s of log -> $LOG)"
env "${atlas_env[@]}" QSG_RENDERER_DEBUG=render QSG_RENDER_TIMING=1 "$BIN" > "$LOG" 2>&1 &
pid=$!
cleanup() { [[ "${KEEP:-0}" == 1 ]] || kill "$pid" 2>/dev/null || true; }
trap cleanup EXIT
echo
echo "[batches] Drive the app to the scene you want to measure:"
echo " album page · NPB Large · band ON · track PLAYING · background on"
echo "[batches] Let it settle, then press Enter to open the ${SECS}s sample window."
read -r _ || true
# Everything written before this instant is startup and navigation. The sample
# is only what lands after — that is the whole point of waiting for you.
mark=$(stat -c %s "$LOG" 2>/dev/null || stat -f %z "$LOG")
echo "[batches] sampling ${SECS}s..."
sleep "$SECS"
sample="$(mktemp "${TMPDIR:-/tmp}/qbz-batches-sample-XXXXXX.log")"
tail -c "+$((mark + 1))" "$LOG" > "$sample"
if ! kill -0 "$pid" 2>/dev/null; then
echo "[batches] WARNING: the app exited during the sample window — this is a" >&2
echo " real exit, not the script; check the tail of $LOG" >&2
fi
# The LAST block in the window: the settled tree, after any delegate churn from
# scrolling or a track change has finished.
opaque=$(grep -a 'Opaque:' "$sample" | tail -1 || true)
alpha=$(grep -a 'Alpha:' "$sample" | tail -1 || true)
if [[ -z "$alpha" ]]; then
echo "[batches] no renderer output in the window — did the scene stop repainting?" >&2
echo "[batches] (a paused player with no animation legitimately emits nothing)" >&2
echo "[batches] log: $LOG" >&2
exit 1
fi
echo
echo " $opaque"
echo " $alpha"
echo
# nodes-per-batch is the health metric: 1.0 means every node pays its own draw
# call, higher is better. Under ~2 says the tree is fighting the batcher, and
# clip state is the usual reason (Qt cannot merge across different clip roots).
python3 - "$alpha" "$opaque" <<'PY'
import re, sys
def parse(s):
m = re.search(r'(\d+)\s+nodes in\s+(\d+)\s+batches', s or '')
return (int(m.group(1)), int(m.group(2))) if m else None
a, o = parse(sys.argv[1]), parse(sys.argv[2])
if a:
print(f" alpha nodes/batch = {a[0]/max(a[1],1):.2f} (1.0 = no batching at all)")
if o:
print(f" opaque nodes/batch = {o[0]/max(o[1],1):.2f}")
if a and o:
print(f" TOTAL batches per frame = {a[1] + o[1]} <- the number to drive down")
PY
# PRESENT RATE — the number that decides whether hunting repaint sources is even
# the right hunt. Qt Quick redraws and presents the WHOLE window whenever
# anything dirties the scene, and on a hybrid laptop whose external monitor
# hangs off the dGPU every present is also a frame KWin composites and scans out
# there. So the GPU cost is presents/s x window area, and presents/s is
# MEASURABLE instead of guessable: `polishAndSync: elapsed since last call` is
# the frame period. Read this before proposing any mechanism. ~30/s = one
# source. ~60/s = two unsynchronised ones. 120+/s = something runs at display
# rate. Five hypotheses died in this campaign for want of this one number.
python3 - "$sample" <<'PYFPS'
import re, sys, statistics
txt = open(sys.argv[1], errors="ignore").read()
per = [int(m) for m in re.findall(r"elapsed since last call: (\d+) ms", txt)]
per = [p for p in per if p > 0]
if per:
med = statistics.median(per)
fast = sum(1 for p in per if p <= 8)
print(f" presents: {len(per)} sampled, median period {med:.0f} ms -> ~{1000/max(med,1):.0f}/s")
print(f" arriving <=8ms apart: {fast} ({100*fast/len(per):.0f}%) <- display-rate source if high")
else:
print(" presents: no timing lines (QSG_RENDER_TIMING not honoured?)")
PYFPS
# Per-frame composition of the last block, which is what a clip audit targets.
last_start=$(grep -an 'Rendering:' "$sample" | tail -1 | cut -d: -f1 || true)
if [[ -n "$last_start" ]]; then
block=$(tail -n "+$last_start" "$sample")
echo
printf ' last frame: clip=%s noclip=%s unmerged=%s\n' \
"$(grep -c '\[ clip\]' <<<"$block" || true)" \
"$(grep -c '\[noclip\]' <<<"$block" || true)" \
"$(grep -c '\[unmerged\]' <<<"$block" || true)"
fi
echo
echo " sample: $sample"
echo " full log: $LOG"