From b136c9cf5d41e157eb37bca9c2bb4520d0b5998a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:01:32 -0600 Subject: [PATCH] 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 --- crates/qbz-qt/build.rs | 59 ++++- crates/qbz-qt/qml/assets/shaders/ambient.frag | 182 ++++++++++++++++ .../qml/assets/shaders/ambient.frag.qsb | Bin 0 -> 7751 bytes crates/qbz-qt/qml/cards/AlbumCard.qml | 10 +- crates/qbz-qt/qml/cards/ArtistCard.qml | 6 +- crates/qbz-qt/qml/cards/CollectionMosaic.qml | 3 +- crates/qbz-qt/qml/cards/LabelCard.qml | 6 +- crates/qbz-qt/qml/cards/PlaylistCard.qml | 4 +- crates/qbz-qt/qml/cards/PlaylistCollage.qml | 3 +- crates/qbz-qt/qml/cards/RadioCard.qml | 17 +- crates/qbz-qt/qml/cards/SlimCard.qml | 4 +- crates/qbz-qt/qml/cards/TrackCard.qml | 4 +- .../qbz-qt/qml/controls/BrowseGenreButton.qml | 7 +- .../qbz-qt/qml/controls/QbzCircleAction.qml | 7 +- crates/qbz-qt/qml/controls/QbzLineEdit.qml | 18 +- crates/qbz-qt/qml/controls/QbzNavButton.qml | 3 +- crates/qbz-qt/qml/controls/QbzSegToggle.qml | 12 +- crates/qbz-qt/qml/controls/QbzSelect.qml | 7 +- crates/qbz-qt/qml/controls/QbzTabBar.qml | 9 +- .../qbz-qt/qml/controls/QbzToggleButton.qml | 6 +- .../qbz-qt/qml/controls/QualityBadgeFull.qml | 16 +- crates/qbz-qt/qml/controls/ViewModeToggle.qml | 4 +- crates/qbz-qt/qml/rows/BlacklistRow.qml | 3 +- crates/qbz-qt/qml/shell/AppShell.qml | 201 +++++++++++++----- crates/qbz-qt/qml/shell/HeaderBar.qml | 12 +- crates/qbz-qt/qml/shell/KioskShell.qml | 14 +- crates/qbz-qt/qml/shell/LyricsPanel.qml | 6 +- .../qbz-qt/qml/shell/NowPlayingBarSmall.qml | 27 ++- crates/qbz-qt/qml/shell/PlayerBar.qml | 11 +- crates/qbz-qt/qml/shell/QueuePanel.qml | 25 ++- crates/qbz-qt/qml/shell/Sidebar.qml | 4 +- .../qml/shell/SidebarNowPlayingDock.qml | 10 +- crates/qbz-qt/qml/shell/SongCard.qml | 5 +- crates/qbz-qt/qml/theme/QbzTheme.qml | 25 ++- crates/qbz-qt/qml/views/AlbumListRow.qml | 4 +- crates/qbz-qt/qml/views/AlbumView.qml | 8 +- .../qbz-qt/qml/views/ArtistReleasesView.qml | 2 +- crates/qbz-qt/qml/views/ArtistView.qml | 17 +- .../qbz-qt/qml/views/BlacklistManagerView.qml | 2 +- .../qbz-qt/qml/views/DiscoverBrowseView.qml | 2 +- crates/qbz-qt/qml/views/HomeView.qml | 24 ++- crates/qbz-qt/qml/views/LabelReleasesView.qml | 2 +- crates/qbz-qt/qml/views/LabelView.qml | 6 +- crates/qbz-qt/qml/views/LibraryView.qml | 2 +- crates/qbz-qt/qml/views/LocalAlbumView.qml | 2 +- crates/qbz-qt/qml/views/LocalLibraryView.qml | 2 +- crates/qbz-qt/qml/views/MixView.qml | 2 +- crates/qbz-qt/qml/views/PlayHistoryView.qml | 2 +- .../qbz-qt/qml/views/PlaylistBrowseView.qml | 2 +- .../qbz-qt/qml/views/PlaylistManagerView.qml | 2 +- crates/qbz-qt/qml/views/PlaylistView.qml | 8 +- crates/qbz-qt/qml/views/SearchView.qml | 14 +- crates/qbz-qt/qml/views/SectionRail.qml | 13 +- .../qml/views/myqbz/MyQbzDetailView.qml | 2 +- .../qbz-qt/qml/views/myqbz/MyQbzGridView.qml | 2 +- crates/qbz-qt/src/ambient_qt.rs | 42 ++-- crates/qbz-qt/src/main.rs | 9 +- crates/qbz-qt/src/renderer_qt.rs | 2 +- crates/qbz-qt/src/theme_qt.rs | 14 ++ scripts/qt-batches.sh | 160 ++++++++++++++ 60 files changed, 915 insertions(+), 162 deletions(-) create mode 100644 crates/qbz-qt/qml/assets/shaders/ambient.frag create mode 100644 crates/qbz-qt/qml/assets/shaders/ambient.frag.qsb create mode 100755 scripts/qt-batches.sh diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index a9cfcc5d3..ed54d7163 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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 { @@ -71,6 +109,18 @@ fn find_qsb() -> Option { /// /// 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" { diff --git a/crates/qbz-qt/qml/assets/shaders/ambient.frag b/crates/qbz-qt/qml/assets/shaders/ambient.frag new file mode 100644 index 000000000..080032e06 --- /dev/null +++ b/crates/qbz-qt/qml/assets/shaders/ambient.frag @@ -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); +} diff --git a/crates/qbz-qt/qml/assets/shaders/ambient.frag.qsb b/crates/qbz-qt/qml/assets/shaders/ambient.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..4f1462e3ef362e303bf332756972fc4eb896334a GIT binary patch literal 7751 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 W#;rJh!F1~aE54x~SeP2hSpEmbUUKFD literal 0 HcmV?d00001 diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index bfb6e92c9..ab1fec065 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/ArtistCard.qml b/crates/qbz-qt/qml/cards/ArtistCard.qml index 526b50727..0bf6168ea 100644 --- a/crates/qbz-qt/qml/cards/ArtistCard.qml +++ b/crates/qbz-qt/qml/cards/ArtistCard.qml @@ -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 } diff --git a/crates/qbz-qt/qml/cards/CollectionMosaic.qml b/crates/qbz-qt/qml/cards/CollectionMosaic.qml index 01a1b9da4..beaf8a1dd 100644 --- a/crates/qbz-qt/qml/cards/CollectionMosaic.qml +++ b/crates/qbz-qt/qml/cards/CollectionMosaic.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/LabelCard.qml b/crates/qbz-qt/qml/cards/LabelCard.qml index f9f2ce2b5..f9030efc0 100644 --- a/crates/qbz-qt/qml/cards/LabelCard.qml +++ b/crates/qbz-qt/qml/cards/LabelCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/PlaylistCard.qml b/crates/qbz-qt/qml/cards/PlaylistCard.qml index 6ed02d67a..b0e343c98 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCard.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/PlaylistCollage.qml b/crates/qbz-qt/qml/cards/PlaylistCollage.qml index edca35934..408cb8d4f 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCollage.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCollage.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/RadioCard.qml b/crates/qbz-qt/qml/cards/RadioCard.qml index a145a12f2..52243c401 100644 --- a/crates/qbz-qt/qml/cards/RadioCard.qml +++ b/crates/qbz-qt/qml/cards/RadioCard.qml @@ -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 } diff --git a/crates/qbz-qt/qml/cards/SlimCard.qml b/crates/qbz-qt/qml/cards/SlimCard.qml index 6a250aa20..9930ee04d 100644 --- a/crates/qbz-qt/qml/cards/SlimCard.qml +++ b/crates/qbz-qt/qml/cards/SlimCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/TrackCard.qml b/crates/qbz-qt/qml/cards/TrackCard.qml index 5223fcad7..20e2fa84b 100644 --- a/crates/qbz-qt/qml/cards/TrackCard.qml +++ b/crates/qbz-qt/qml/cards/TrackCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml index 9beeb734a..221fdefb0 100644 --- a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml +++ b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzCircleAction.qml b/crates/qbz-qt/qml/controls/QbzCircleAction.qml index d2474992a..3476153fb 100644 --- a/crates/qbz-qt/qml/controls/QbzCircleAction.qml +++ b/crates/qbz-qt/qml/controls/QbzCircleAction.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzLineEdit.qml b/crates/qbz-qt/qml/controls/QbzLineEdit.qml index ba5ca375b..bc560cf65 100644 --- a/crates/qbz-qt/qml/controls/QbzLineEdit.qml +++ b/crates/qbz-qt/qml/controls/QbzLineEdit.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzNavButton.qml b/crates/qbz-qt/qml/controls/QbzNavButton.qml index 5a31550b8..999c977bf 100644 --- a/crates/qbz-qt/qml/controls/QbzNavButton.qml +++ b/crates/qbz-qt/qml/controls/QbzNavButton.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzSegToggle.qml b/crates/qbz-qt/qml/controls/QbzSegToggle.qml index 76a65c617..5c2ec7eea 100644 --- a/crates/qbz-qt/qml/controls/QbzSegToggle.qml +++ b/crates/qbz-qt/qml/controls/QbzSegToggle.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzSelect.qml b/crates/qbz-qt/qml/controls/QbzSelect.qml index 744a72647..7749ff36f 100644 --- a/crates/qbz-qt/qml/controls/QbzSelect.qml +++ b/crates/qbz-qt/qml/controls/QbzSelect.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzTabBar.qml b/crates/qbz-qt/qml/controls/QbzTabBar.qml index 7f5bb0033..1f05a8edf 100644 --- a/crates/qbz-qt/qml/controls/QbzTabBar.qml +++ b/crates/qbz-qt/qml/controls/QbzTabBar.qml @@ -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 { diff --git a/crates/qbz-qt/qml/controls/QbzToggleButton.qml b/crates/qbz-qt/qml/controls/QbzToggleButton.qml index a747c3ce3..348c67567 100644 --- a/crates/qbz-qt/qml/controls/QbzToggleButton.qml +++ b/crates/qbz-qt/qml/controls/QbzToggleButton.qml @@ -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 { diff --git a/crates/qbz-qt/qml/controls/QualityBadgeFull.qml b/crates/qbz-qt/qml/controls/QualityBadgeFull.qml index ac157b5cb..54d30414a 100644 --- a/crates/qbz-qt/qml/controls/QualityBadgeFull.qml +++ b/crates/qbz-qt/qml/controls/QualityBadgeFull.qml @@ -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 } } } diff --git a/crates/qbz-qt/qml/controls/ViewModeToggle.qml b/crates/qbz-qt/qml/controls/ViewModeToggle.qml index 772f41c03..f1bea49d0 100644 --- a/crates/qbz-qt/qml/controls/ViewModeToggle.qml +++ b/crates/qbz-qt/qml/controls/ViewModeToggle.qml @@ -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 { diff --git a/crates/qbz-qt/qml/rows/BlacklistRow.qml b/crates/qbz-qt/qml/rows/BlacklistRow.qml index 92c02edb8..6541ccffe 100644 --- a/crates/qbz-qt/qml/rows/BlacklistRow.qml +++ b/crates/qbz-qt/qml/rows/BlacklistRow.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index f3f476a6b..656f51b78 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -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 - 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). - color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain - border.width: root.ambientOn ? 1 : 0 - border.color: theme.frostBorder - clip: true + color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard - // 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. - ContentRouter { - id: contentRouter + // 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), 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. + // + // `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. + 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 diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index f86747c30..0ad9180c7 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -61,8 +61,8 @@ 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. property var hostWindow: null @@ -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" @@ -961,7 +965,7 @@ Rectangle { } } - QbzIconButton { activeBackground: true + QbzIconButton { activeBackground: true name: "menu" anchors.verticalCenter: parent.verticalCenter onClicked: appMenu.open() diff --git a/crates/qbz-qt/qml/shell/KioskShell.qml b/crates/qbz-qt/qml/shell/KioskShell.qml index 8579da9bb..230bc0c12 100644 --- a/crates/qbz-qt/qml/shell/KioskShell.qml +++ b/crates/qbz-qt/qml/shell/KioskShell.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/LyricsPanel.qml b/crates/qbz-qt/qml/shell/LyricsPanel.qml index eb1e16c52..29b4c0864 100644 --- a/crates/qbz-qt/qml/shell/LyricsPanel.qml +++ b/crates/qbz-qt/qml/shell/LyricsPanel.qml @@ -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 } diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index de0a671e4..e45e9dc78 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -43,8 +43,8 @@ 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 diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 57b84a57e..c73036507 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -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) diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index a77f3bb1d..025990929 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -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] || "") : "" @@ -956,7 +961,7 @@ Rectangle { Row { id: paginator spacing: theme.spacingMd - QbzIconButton { btnSize: 30 + QbzIconButton { btnSize: 30 name: "chevron-left" iconSize: 15 btnEnabled: (doc.page || 0) > 0 @@ -968,7 +973,7 @@ Rectangle { color: theme.textMuted font.pixelSize: 12 } - QbzIconButton { btnSize: 30 + QbzIconButton { btnSize: 30 name: "chevron-right" iconSize: 15 btnEnabled: (doc.page || 0) < (doc.pageCount || 1) - 1 @@ -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 diff --git a/crates/qbz-qt/qml/shell/Sidebar.qml b/crates/qbz-qt/qml/shell/Sidebar.qml index 591c1888d..226f814e7 100644 --- a/crates/qbz-qt/qml/shell/Sidebar.qml +++ b/crates/qbz-qt/qml/shell/Sidebar.qml @@ -309,8 +309,8 @@ 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). clip: true diff --git a/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml b/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml index cc0bf3888..b8e108430 100644 --- a/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml +++ b/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/SongCard.qml b/crates/qbz-qt/qml/shell/SongCard.qml index f99d08dbb..38f01c46d 100644 --- a/crates/qbz-qt/qml/shell/SongCard.qml +++ b/crates/qbz-qt/qml/shell/SongCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/theme/QbzTheme.qml b/crates/qbz-qt/qml/theme/QbzTheme.qml index 39222aa3d..33cea7a2a 100644 --- a/crates/qbz-qt/qml/theme/QbzTheme.qml +++ b/crates/qbz-qt/qml/theme/QbzTheme.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml index 2c53b1424..6e7999657 100644 --- a/crates/qbz-qt/qml/views/AlbumListRow.qml +++ b/crates/qbz-qt/qml/views/AlbumListRow.qml @@ -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 || "") diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index 8324317c5..67047f1f0 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -33,8 +33,8 @@ 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 // view — the view's own fill must round instead. @@ -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 diff --git a/crates/qbz-qt/qml/views/ArtistReleasesView.qml b/crates/qbz-qt/qml/views/ArtistReleasesView.qml index b873d4078..c69d69e8d 100644 --- a/crates/qbz-qt/qml/views/ArtistReleasesView.qml +++ b/crates/qbz-qt/qml/views/ArtistReleasesView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index 39540ae9c..0a2c4e9d2 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -45,8 +45,8 @@ 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 // view — the view's own fill must round instead. @@ -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 } diff --git a/crates/qbz-qt/qml/views/BlacklistManagerView.qml b/crates/qbz-qt/qml/views/BlacklistManagerView.qml index 72b98774a..408dbcc11 100644 --- a/crates/qbz-qt/qml/views/BlacklistManagerView.qml +++ b/crates/qbz-qt/qml/views/BlacklistManagerView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/DiscoverBrowseView.qml b/crates/qbz-qt/qml/views/DiscoverBrowseView.qml index bdb8d84b3..b245af418 100644 --- a/crates/qbz-qt/qml/views/DiscoverBrowseView.qml +++ b/crates/qbz-qt/qml/views/DiscoverBrowseView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index 770a1aa57..f92a4da52 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -58,8 +58,8 @@ 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 // view — the view's own fill must round instead. @@ -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. diff --git a/crates/qbz-qt/qml/views/LabelReleasesView.qml b/crates/qbz-qt/qml/views/LabelReleasesView.qml index 2b0d527d9..6b60c6ffd 100644 --- a/crates/qbz-qt/qml/views/LabelReleasesView.qml +++ b/crates/qbz-qt/qml/views/LabelReleasesView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/LabelView.qml b/crates/qbz-qt/qml/views/LabelView.qml index 60ae92096..cdf044ac9 100644 --- a/crates/qbz-qt/qml/views/LabelView.qml +++ b/crates/qbz-qt/qml/views/LabelView.qml @@ -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) } diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 56a83cac4..216fbf2a1 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/LocalAlbumView.qml b/crates/qbz-qt/qml/views/LocalAlbumView.qml index a92c8c13d..d3c9f9b45 100644 --- a/crates/qbz-qt/qml/views/LocalAlbumView.qml +++ b/crates/qbz-qt/qml/views/LocalAlbumView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index 63157edb0..600c29de5 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/MixView.qml b/crates/qbz-qt/qml/views/MixView.qml index ca9680c06..0827111ff 100644 --- a/crates/qbz-qt/qml/views/MixView.qml +++ b/crates/qbz-qt/qml/views/MixView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/PlayHistoryView.qml b/crates/qbz-qt/qml/views/PlayHistoryView.qml index 62ced62be..37eed68ff 100644 --- a/crates/qbz-qt/qml/views/PlayHistoryView.qml +++ b/crates/qbz-qt/qml/views/PlayHistoryView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/PlaylistBrowseView.qml b/crates/qbz-qt/qml/views/PlaylistBrowseView.qml index 64f618bc7..0e0c686f3 100644 --- a/crates/qbz-qt/qml/views/PlaylistBrowseView.qml +++ b/crates/qbz-qt/qml/views/PlaylistBrowseView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/PlaylistManagerView.qml b/crates/qbz-qt/qml/views/PlaylistManagerView.qml index 73fef3c8e..3192ac2c1 100644 --- a/crates/qbz-qt/qml/views/PlaylistManagerView.qml +++ b/crates/qbz-qt/qml/views/PlaylistManagerView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index 97573f9f6..899f66d13 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -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 { diff --git a/crates/qbz-qt/qml/views/SearchView.qml b/crates/qbz-qt/qml/views/SearchView.qml index c80f5df61..413c44bc5 100644 --- a/crates/qbz-qt/qml/views/SearchView.qml +++ b/crates/qbz-qt/qml/views/SearchView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/SectionRail.qml b/crates/qbz-qt/qml/views/SectionRail.qml index 1ad8df18f..50b729579 100644 --- a/crates/qbz-qt/qml/views/SectionRail.qml +++ b/crates/qbz-qt/qml/views/SectionRail.qml @@ -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 { diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml index e2d1524b8..692932f4b 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml index 7ea5bc064..d24b6651e 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml @@ -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). diff --git a/crates/qbz-qt/src/ambient_qt.rs b/crates/qbz-qt/src/ambient_qt.rs index 68da0e73c..b745d22cf 100644 --- a/crates/qbz-qt/src/ambient_qt.rs +++ b/crates/qbz-qt/src/ambient_qt.rs @@ -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; diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 1aee68a56..63f7b9af4 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -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)); } diff --git a/crates/qbz-qt/src/renderer_qt.rs b/crates/qbz-qt/src/renderer_qt.rs index 3f764fc68..ee83fa928 100644 --- a/crates/qbz-qt/src/renderer_qt.rs +++ b/crates/qbz-qt/src/renderer_qt.rs @@ -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 diff --git a/crates/qbz-qt/src/theme_qt.rs b/crates/qbz-qt/src/theme_qt.rs index af47d5f6e..199ad5e0a 100644 --- a/crates/qbz-qt/src/theme_qt.rs +++ b/crates/qbz-qt/src/theme_qt.rs @@ -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)]), diff --git a/scripts/qt-batches.sh b/scripts/qt-batches.sh new file mode 100755 index 000000000..723e83667 --- /dev/null +++ b/scripts/qt-batches.sh @@ -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"