feat(qt): ambient background port, clip sweep and measurement tooling
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc), uncommitted until now: the app-wide dynamic background in both modes (AmbientField with GPU/Canvas arms, AppShell layering, ambient palette pipeline), the wave-1 clip/batch sweep across the tree (alpha batches 449 -> 327, reproducible), the rounded-cover and icon-tint arms, and scripts/qt-batches.sh (batches + presents/s sampler). The GPU behaviour of these pieces was finalized in the follow-up pulse commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/ GPU-COST-INVESTIGATION.md for the full campaign. km
This commit is contained in:
60 files changed
+893
-140
No files matched your search
+58
-1
@@ -26,6 +26,44 @@ const SHADER_GLSL: &str = "100 es,300 es,310 es,320 es,120,150,440";
|
||||
const SHADER_HLSL: &str = "50";
|
||||
const SHADER_MSL: &str = "12";
|
||||
|
||||
/// The same list without `100 es`, for shaders that use unsigned integer
|
||||
/// arithmetic. GLSL ES 1.00 (OpenGL ES 2.0) has NO uint type, so SPIRV-Cross
|
||||
/// has to re-type the literals as int and refuses when that would make one
|
||||
/// negative — `qsb` then fails the whole bake, not just that variant.
|
||||
///
|
||||
/// The one shader in this position is `ambient.frag`, and its hash CANNOT drop
|
||||
/// to floats: the reference's own comment (ambient.wgsl) records that a float
|
||||
/// hash lets the same lattice corner disagree between adjacent cells and draws
|
||||
/// a straight seam through the warp field. So the ES 2.0 level is dropped
|
||||
/// instead, and AmbientField's Canvas arm covers that hardware — which is what
|
||||
/// the fallback exists for.
|
||||
const SHADER_GLSL_NO_ES100: &str = "300 es,310 es,320 es,120,150,440";
|
||||
|
||||
/// Opt out of `100 es` by putting this marker anywhere in the shader source.
|
||||
const NO_ES100_MARKER: &str = "QSB-SKIP-GLES100";
|
||||
|
||||
/// Is `out` already a current bake of `src`? True only when it exists and is
|
||||
/// no older than BOTH the shader source and this build script — the script
|
||||
/// because the variant lists and the `100 es` opt-out live here, so editing
|
||||
/// them has to invalidate every `.qsb` even though no `.frag` moved.
|
||||
///
|
||||
/// Anything unreadable answers false: a bake we cannot reason about is one we
|
||||
/// redo. Equal mtimes count as current (a same-second write is the normal case
|
||||
/// right after a bake, and treating it as stale would restart the churn).
|
||||
fn up_to_date(src: &Path, out: &Path) -> bool {
|
||||
let mtime = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
|
||||
let (Some(out_t), Some(src_t)) = (mtime(out), mtime(src)) else {
|
||||
return false;
|
||||
};
|
||||
if out_t < src_t {
|
||||
return false;
|
||||
}
|
||||
match mtime(Path::new("build.rs")) {
|
||||
Some(script_t) => out_t >= script_t,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate Qt's `qsb`. PATH first, then the usual install layouts (Linux
|
||||
/// distro, Homebrew on the Mac mini).
|
||||
fn find_qsb() -> Option<std::path::PathBuf> {
|
||||
@@ -71,6 +109,18 @@ fn find_qsb() -> Option<std::path::PathBuf> {
|
||||
///
|
||||
/// Missing `qsb` is a WARNING, not an error: the committed `.qsb` are still
|
||||
/// in the tree and still load, so a box without Qt's shader tools can build.
|
||||
///
|
||||
/// THE BAKE MUST BE IDEMPOTENT, and that is not a nicety — it is the whole
|
||||
/// reason `up_to_date` exists. `qsb` REWRITES its output unconditionally:
|
||||
/// byte-identical content, brand-new mtime (measured — same md5, mtime moves).
|
||||
/// The `.qsb` live under `qml/assets`, which `main` watches with
|
||||
/// `cargo:rerun-if-changed=qml` plus one line per qrc file, so an
|
||||
/// unconditional bake made every build touch a file cargo was watching. Cargo
|
||||
/// then re-ran this script on the NEXT invocation, which baked again, which
|
||||
/// dirtied the watch again: `cargo build` never reached a no-op and every
|
||||
/// `qt-run.sh` recompiled the crate from the build script down. Introduced in
|
||||
/// 21e941bdf together with the bake itself, and it is why the tree started
|
||||
/// rebuilding on every run when nothing had changed.
|
||||
fn build_shaders() {
|
||||
let dir = Path::new("qml/assets/shaders");
|
||||
if !dir.is_dir() {
|
||||
@@ -91,8 +141,15 @@ fn build_shaders() {
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", src.display());
|
||||
let out = src.with_extension(format!("{ext}.qsb"));
|
||||
if up_to_date(&src, &out) {
|
||||
continue;
|
||||
}
|
||||
let glsl = match std::fs::read_to_string(&src) {
|
||||
Ok(text) if text.contains(NO_ES100_MARKER) => SHADER_GLSL_NO_ES100,
|
||||
_ => SHADER_GLSL,
|
||||
};
|
||||
let mut cmd = std::process::Command::new(&qsb);
|
||||
cmd.args(["--glsl", SHADER_GLSL, "--hlsl", SHADER_HLSL, "--msl", SHADER_MSL]);
|
||||
cmd.args(["--glsl", glsl, "--hlsl", SHADER_HLSL, "--msl", SHADER_MSL]);
|
||||
// Vertex shaders used by Qt Quick need the batching rewrite, or the
|
||||
// scene graph cannot batch the item and falls back per-node.
|
||||
if ext == "vert" {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
#version 440
|
||||
|
||||
// Ambient — the app-wide dynamic background, a 1:1 GLSL port of
|
||||
// crates/qbz-ui/ui/shaders/ambient.wgsl (WGSL, wgpu scene 7). Every constant,
|
||||
// every orbit and every curve below is the same number the reference uses; the
|
||||
// only differences are mechanical (WGSL -> GLSL 440, uniform block instead of a
|
||||
// bind group, and `time` arrives as a QML property instead of a shared frame
|
||||
// uniform).
|
||||
//
|
||||
// WHY THIS IS A SHADER AND NOT A CANVAS. The Canvas version this replaces
|
||||
// painted four radial gradients over a BLACK base, so anywhere outside a blob's
|
||||
// radius the window was #000000 — measured on the owner's 2026-08-04 side by
|
||||
// side: Qt at (1600,600) and (1690,880) was (0,0,0) while Slint was (58,51,5)
|
||||
// and (29,27,10). The reference cannot produce black: `col` is the
|
||||
// metaball-WEIGHTED album colour (the field divides out, so it is defined
|
||||
// everywhere) and the darkest it is ever scaled is `mix(0.42, 1.12, shape)` at
|
||||
// shape = 0. The floor is 42% of the album colour across the WHOLE window,
|
||||
// which is why the Slint reads as one wash that brightens into lobes and the
|
||||
// Canvas read as spotlights on black. That floor is not reachable by adding
|
||||
// gradients — it needs the per-pixel weighted average, i.e. this.
|
||||
//
|
||||
// Canvas remains as the fallback arm for the software scene graph (see
|
||||
// AmbientField.qml), where it now paints the same floor as a flat base.
|
||||
//
|
||||
// The uniform block is the ShaderEffect contract: qt_Matrix and qt_Opacity are
|
||||
// supplied by Qt, everything after them must exist as a `property real` /
|
||||
// `property color` on the effect item. Qt resolves them BY NAME out of the
|
||||
// .qsb's reflection data, so the QML declaration order is free — but a name
|
||||
// with no matching property is silently left at zero, which is why every
|
||||
// member below is spelled identically on both sides.
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
vec4 primary;
|
||||
vec4 secondary;
|
||||
vec4 accent;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
float levelSmooth;
|
||||
float resX;
|
||||
float resY;
|
||||
float dim;
|
||||
};
|
||||
|
||||
// QSB-SKIP-GLES100 — build.rs drops the `100 es` variant for this file. GLSL ES
|
||||
// 1.00 has no uint, SPIRV-Cross re-types the hash constants below as int, the
|
||||
// large ones go negative and qsb fails the WHOLE bake. Dropping the ES 2.0
|
||||
// level costs nothing here: AmbientField falls back to its Canvas arm wherever
|
||||
// the effect cannot load, and the hash cannot become float-based (see below).
|
||||
//
|
||||
// INTEGER lattice hash (bit-exact), wgsl:hash2. The reference's comment is
|
||||
// load-bearing and applies verbatim here: float hashes leave the result to f32
|
||||
// rounding, so the SAME lattice corner hashed from two adjacent cells can
|
||||
// disagree and draw a "wallpaper join" seam through the warp field. uint
|
||||
// arithmetic has no rounding.
|
||||
float hash2(vec2 p) {
|
||||
uint h = uint(int(p.x)) * 0x8da6b343u + uint(int(p.y)) * 0xd8163841u;
|
||||
h = (h ^ (h >> 15u)) * 0x2c1b3c6du;
|
||||
h = (h ^ (h >> 12u)) * 0x297a2d39u;
|
||||
h = h ^ (h >> 15u);
|
||||
return float(h >> 8u) * (1.0 / 16777216.0);
|
||||
}
|
||||
|
||||
float vnoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
float a = hash2(i);
|
||||
float b = hash2(i + vec2(1.0, 0.0));
|
||||
float c = hash2(i + vec2(0.0, 1.0));
|
||||
float d = hash2(i + vec2(1.0, 1.0));
|
||||
vec2 w = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(a, b, w.x), mix(c, d, w.x), w.y);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0;
|
||||
float amp = 0.55;
|
||||
vec2 q = p;
|
||||
v += amp * vnoise(q); q *= 2.02; amp *= 0.5;
|
||||
v += amp * vnoise(q); q *= 2.03; amp *= 0.5;
|
||||
v += amp * vnoise(q);
|
||||
return v;
|
||||
}
|
||||
|
||||
// Metaball potential (r^2/d^2): summed over several movers the iso-surface
|
||||
// merges and splits organically. Unlike a gaussian blob this has a LONG TAIL —
|
||||
// that tail is what fills the window between the lobes, and it is precisely
|
||||
// what a canvas radial gradient (which terminates at its radius) cannot do.
|
||||
float mball(vec2 uv, vec2 c, float r) {
|
||||
vec2 d = uv - c;
|
||||
return (r * r) / (dot(d, d) + 0.0009);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Aspect-correct so the drift looks even on wide windows. NOTE the space
|
||||
// this puts us in: x spans [0, aspect] and y spans [0, 1], so the radius
|
||||
// below is a fraction of the window HEIGHT — the Canvas port had it as
|
||||
// 0.36 * max(W, H), which on a 1700x1400 window made the blobs 29% too big.
|
||||
float aspect = resX / max(resY, 1.0);
|
||||
vec2 uv = qt_TexCoord0;
|
||||
uv.x *= aspect;
|
||||
|
||||
// Clock. Fast enough that the flow is visible in seconds (blob orbits are
|
||||
// ~10-18 s); `levelSmooth` adds a gentle breathe when audio is flowing.
|
||||
float t = time * 0.75;
|
||||
float breathe = 1.0 + 0.12 * levelSmooth;
|
||||
|
||||
// Two-octave domain warp. The low octave does the big organic flow; the
|
||||
// high octave stays small and lower-frequency — a strong high-freq warp
|
||||
// folds the metaball field into sharp creases that read as hard edges over
|
||||
// the translucent UI.
|
||||
vec2 w1 = vec2(
|
||||
fbm(uv * 1.3 + vec2(t * 0.5, t * 0.2)),
|
||||
fbm(uv * 1.3 + vec2(-t * 0.3, t * 0.45))
|
||||
);
|
||||
vec2 w2 = vec2(
|
||||
fbm(uv * 2.3 + vec2(-t * 0.6, t * 0.45)),
|
||||
fbm(uv * 2.3 + vec2(t * 0.55, -t * 0.35))
|
||||
);
|
||||
vec2 p = uv + (w1 - 0.5) * 0.68 + (w2 - 0.5) * 0.14;
|
||||
|
||||
// Four album-coloured metaballs on big wandering orbits.
|
||||
vec3 c4 = mix(primary.rgb, accent.rgb, 0.5);
|
||||
vec2 cA = vec2((0.32 + 0.30 * sin(t * 0.40)) * aspect, 0.42 + 0.30 * cos(t * 0.33));
|
||||
vec2 cB = vec2((0.66 + 0.30 * sin(t * 0.35 + 2.1)) * aspect, 0.56 + 0.32 * cos(t * 0.29 + 1.3));
|
||||
vec2 cC = vec2((0.50 + 0.34 * cos(t * 0.31 + 4.0)) * aspect, 0.36 + 0.30 * sin(t * 0.45 + 3.2));
|
||||
vec2 cD = vec2((0.46 + 0.32 * sin(t * 0.27 + 5.3)) * aspect, 0.64 + 0.28 * cos(t * 0.49 + 0.7));
|
||||
|
||||
float rr = 0.34 * breathe;
|
||||
float fA = mball(p, cA, rr);
|
||||
float fB = mball(p, cB, rr * 0.95);
|
||||
float fC = mball(p, cC, rr * 0.88);
|
||||
float fD = mball(p, cD, rr * 0.82);
|
||||
float field = fA + fB + fC + fD;
|
||||
|
||||
// Metaball-weighted album colour (which lobe dominates here).
|
||||
vec3 col = (primary.rgb * fA + secondary.rgb * fB + accent.rgb * fC + c4 * fD)
|
||||
/ (field + 0.0001);
|
||||
|
||||
// The amoeba structure: the iso-surface. A WIDE smoothstep so the lobes melt
|
||||
// into the base over a long soft gradient instead of a hard rim, and a
|
||||
// gentler bright/dark spread so transitions never snap. 0.42 is the floor
|
||||
// this whole file exists for.
|
||||
float shape = smoothstep(0.45, 3.4, field);
|
||||
col *= mix(0.42, 1.12, shape);
|
||||
|
||||
// Push saturation/contrast a little so the lobes read as album colour, not
|
||||
// a muddy average — but not so hard it re-sharpens the transitions.
|
||||
float luma = dot(col, vec3(0.299, 0.587, 0.114));
|
||||
col = mix(vec3(luma), col, 1.28);
|
||||
|
||||
// Vertical falloff -> a touch darker at the very top/bottom edges so chrome
|
||||
// (titlebar, player bar) sits on calmer colour. QUADRATIC, not the linear
|
||||
// black ramp the Canvas used.
|
||||
float vshade = 1.0 - 0.16 * pow(abs(qt_TexCoord0.y - 0.5) * 2.0, 2.0);
|
||||
col *= vshade;
|
||||
|
||||
// Overall brightness — vivid; the dim scrim above this layer provides the
|
||||
// legibility dim, so the base can stay bright without glaring.
|
||||
col = clamp(col, vec3(0.0), vec3(1.0)) * 0.92;
|
||||
|
||||
// A touch of grain (dither) to break up 8-bit banding on the smooth
|
||||
// gradient — banding rings read as faint hard edges too.
|
||||
float grain = (vnoise(qt_TexCoord0 * vec2(resX, resY) * 0.5) - 0.5) * 0.022;
|
||||
col += vec3(grain);
|
||||
|
||||
// The legibility scrim, FOLDED IN. The reference paints it as a separate
|
||||
// full-window black Rectangle at `dim` alpha over the field
|
||||
// (AppShell.slint:236-241), which is arithmetically `field * (1 - dim)` —
|
||||
// identical to doing it here, one multiply instead of a whole extra
|
||||
// full-screen blend pass in every frame the scene redraws. With the
|
||||
// background up the shell is already an all-alpha stack, so a pass removed
|
||||
// is a pass removed from EVERY frame, not just the 30 the field owns.
|
||||
col *= (1.0 - clamp(dim, 0.0, 1.0));
|
||||
|
||||
col = clamp(col, vec3(0.0), vec3(1.0));
|
||||
// Premultiplied, like every other effect in this tree.
|
||||
fragColor = vec4(col * qt_Opacity, qt_Opacity);
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,7 +72,9 @@ Rectangle {
|
||||
height: 200
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
// No clip: the child RoundedImage confines itself on both arms, and
|
||||
// a rectangular scissor never produced this radius. A clip is an
|
||||
// unconditional batch root, so this one cost a draw call per item.
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.artSource
|
||||
|
||||
@@ -41,8 +41,13 @@ Rectangle {
|
||||
width: genreRow.width
|
||||
height: btnHeight
|
||||
radius: 6
|
||||
// Resting fill translucent under the dynamic background
|
||||
// (HomeView.slint:89, BrowseHeaderTools.slint:111, PlaylistTagFilter
|
||||
// .slint:77 — the same chip in three mounts). Accent and hover keep theirs.
|
||||
color: root.active ? theme.accent
|
||||
: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
: genreArea.containsMouse
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
|
||||
Row {
|
||||
id: genreRow
|
||||
|
||||
@@ -86,7 +86,12 @@ Rectangle {
|
||||
: ((cbArea.containsMouse || active) ? "#3dffffff" : "#24ffffff"))
|
||||
: (primary
|
||||
? (cbArea.containsMouse && btnEnabled ? theme.accentHover : theme.accent)
|
||||
: ((cbArea.containsMouse || active) ? theme.surfaceHover : theme.surfaceElevated))
|
||||
// Resting fill translucent under the dynamic background
|
||||
// (CircleAction.slint:53-55). The overlay variant above sits on
|
||||
// artwork and keeps its white scale.
|
||||
: ((cbArea.containsMouse || active)
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)))
|
||||
border.width: primary ? 0 : 1.5
|
||||
border.color: overlay ? "#ccffffff" : theme.borderStrong
|
||||
opacity: btnEnabled ? 1.0 : 0.4
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,9 @@ Rectangle {
|
||||
|
||||
width: segBarRow.width
|
||||
height: segBarRow.height
|
||||
color: theme.surfaceElevated
|
||||
// The well: surface-elevated @ 0.5 under the dynamic background
|
||||
// (SegmentedTabBar.slint:108).
|
||||
color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
|
||||
radius: 6
|
||||
|
||||
Row {
|
||||
@@ -45,7 +47,10 @@ Rectangle {
|
||||
width: segRow.implicitWidth
|
||||
height: segRow.implicitHeight
|
||||
radius: 4
|
||||
color: active ? theme.surfaceMain
|
||||
// The ACTIVE cell takes surface-main @ 0.5 — surface-MAIN, not
|
||||
// elevated, and at the chrome alpha (SegmentedTabBar.slint:29).
|
||||
color: active
|
||||
? (theme.ambientOn ? theme.surfaceMainA50 : theme.surfaceMain)
|
||||
: segArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
|
||||
@@ -39,8 +39,12 @@ Rectangle {
|
||||
height: sm ? 30 : 34
|
||||
radius: 6
|
||||
opacity: btnEnabled ? 1.0 : 0.35
|
||||
// Glass under the app-wide dynamic background: the ACTIVE fill goes
|
||||
// translucent so the moving field shows through (ToggleButton.slint:25-29).
|
||||
// Only the active arm changes — surface-hover is already translucent and
|
||||
// the resting arm is transparent, so neither has an alpha to set.
|
||||
color: root.active
|
||||
? theme.surfaceHover
|
||||
? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceHover)
|
||||
: (tbArea.containsMouse && root.btnEnabled ? theme.surfaceElevated : "transparent")
|
||||
|
||||
QbzIcon {
|
||||
|
||||
@@ -37,6 +37,12 @@ Item {
|
||||
property bool compact: false
|
||||
/// Extra proportional scale on top of `compact` (1.0 = none).
|
||||
property real scaleFactor: 1.0
|
||||
/// Cap for the text column, in px. 0 = uncapped, which is what every
|
||||
/// existing call site gets — this exists so the ONE caller that used to
|
||||
/// wrap the badge in a `clip: true` (rows/TrackRow.qml's quality cell) can
|
||||
/// bound the badge instead. A clip there was a batch root per visible row,
|
||||
/// and Qt cannot merge alpha geometry across clip roots.
|
||||
property real maxTextWidth: 0
|
||||
|
||||
readonly property string tierLabel: tier === "hires" ? "HI-RES"
|
||||
: (tier === "mp3" ? "MP3"
|
||||
@@ -110,7 +116,13 @@ Item {
|
||||
// with `width: parent.width` inside an implicitly-sized Column is
|
||||
// a binding loop. Both Texts are unwrapped, so `implicitWidth` is
|
||||
// the natural content width and does not depend on `width`.
|
||||
width: Math.max(tierText.implicitWidth, detailText.implicitWidth)
|
||||
// `implicitWidth` stays the natural content width even with the
|
||||
// elides below (an unwrapped Text does not shrink its implicit
|
||||
// size), so the no-binding-loop argument above still holds.
|
||||
width: root.maxTextWidth > 0
|
||||
? Math.min(root.maxTextWidth,
|
||||
Math.max(tierText.implicitWidth, detailText.implicitWidth))
|
||||
: Math.max(tierText.implicitWidth, detailText.implicitWidth)
|
||||
|
||||
Text {
|
||||
id: tierText
|
||||
@@ -121,6 +133,7 @@ Item {
|
||||
font.weight: Font.Thin
|
||||
font.letterSpacing: 0.5
|
||||
horizontalAlignment: root.bare ? Text.AlignHCenter : Text.AlignLeft
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
id: detailText
|
||||
@@ -130,6 +143,7 @@ Item {
|
||||
font.pixelSize: Math.max(1, Math.round((root.compact ? 8 : 9) * root.sf))
|
||||
font.weight: Font.Thin
|
||||
horizontalAlignment: root.bare ? Text.AlignHCenter : Text.AlignLeft
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ Rectangle {
|
||||
width: 68
|
||||
height: 34
|
||||
radius: 6
|
||||
color: theme.surfaceElevated
|
||||
// The well: surface-elevated @ 0.5 under the dynamic background
|
||||
// (BrowseHeaderTools.slint:155).
|
||||
color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
|
||||
clip: true
|
||||
|
||||
Row {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,12 +8,28 @@
|
||||
// AppShell.slint:358-390. The recovery affordance and logout live in the
|
||||
// header (offline badge flyout + app menu), like the Slint shell.
|
||||
//
|
||||
// The artwork-derived ambient background IS implemented: AmbientField is
|
||||
// mounted below, `ambientOn` derives from QbzShell.ambientMode, and the
|
||||
// content frame swaps to surface-main @ 0.22 with a hairline while it runs.
|
||||
// (The POC-NOTE that used to sit here said the opposite. It was a fossil —
|
||||
// corrected in passing per LESSONS-2026-08-03 L10, which asks that the ones
|
||||
// a diff touches be fixed rather than propagated.)
|
||||
// The artwork-derived ambient background IS implemented, in BOTH of the
|
||||
// reference's modes (AppShell.slint:206-242): 1 = Ambient, the album-triad
|
||||
// metaball field (AmbientField) under a tunable dark scrim; 2 = Blurred art,
|
||||
// the ImmersiveAtmosphere cover look, which carries its own `dim` and takes no
|
||||
// scrim. `theme.ambientOn` says "a mode is picked and a track is loaded";
|
||||
// `QbzShell.ambientMode` says WHICH. Until 2026-08-11 mode 2 rendered as mode 1
|
||||
// (ambient_qt.rs said so out loud), so the two settings looked identical.
|
||||
//
|
||||
// THE LAYERING, which is the part that has to be exact. Three tiers, not one:
|
||||
// chrome (Sidebar / HeaderBar / NPB / queue column / the CONTENT FRAME)
|
||||
// -> surface-card @ 0.5
|
||||
// panel (the inset content pane) -> surface-main @ 0.22 + a 1px hairline,
|
||||
// composited ON TOP OF the frame, because in the reference it is the
|
||||
// frame's CHILD (AppShell.slint:358-414)
|
||||
// views -> transparent, with their thin bars at surface-main @ 0.3 and
|
||||
// their controls at surface-elevated @ 0.5
|
||||
// The frame tier was missing here: `contentFrame` was a sibling of the shell
|
||||
// root, so the 8px gutter around the pane showed the RAW field instead of
|
||||
// card @ 0.5, and the pane composited over the raw field too. Measured on the
|
||||
// owner's side-by-side: Qt's sidebar (27,28,34) against its gutter (50,52,64) —
|
||||
// a bright seam at x=240 — where Slint's sidebar (79,72,23) and gutter
|
||||
// (82,74,20) are the same surface and read as one continuous frame.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
@@ -30,12 +46,11 @@ Rectangle {
|
||||
color: theme.surfaceCard
|
||||
|
||||
// App-wide dynamic background (phase 14): active when the mode pref is
|
||||
// on AND a track is loaded (D4: no track -> opaque theme restored).
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
// The bottom-most visual layer (AppShell.slint:206-242, declared FIRST
|
||||
// so every chrome surface paints above it): the ambient album-colored
|
||||
// field + the dark dim scrim that keeps chrome text legible
|
||||
// (QBZ_BG_DIM-tunable, default 0.35).
|
||||
// on AND a track is loaded (D4: no track -> opaque theme restored). The
|
||||
// predicate itself now lives on the theme object so the ~25 surfaces that
|
||||
// read it cannot drift apart (they did: the carousel fades and the header
|
||||
// search field never got it).
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
// Window-level history navigation: mouse side buttons + two-finger
|
||||
// horizontal touchpad swipe (#653 parity). Declared FIRST so the whole UI
|
||||
// hit-tests on top of it — a scroll reaches it only when nothing above
|
||||
@@ -46,18 +61,45 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
// --- The background layers (AppShell.slint:206-242) --------------------
|
||||
// The bottom-most visual layer, declared before every chrome surface so
|
||||
// they all paint above it. Mode 1 and mode 2 are DIFFERENT looks and are
|
||||
// mutually exclusive; neither is mounted while a mode is off or nothing is
|
||||
// playing, which is the D4 "opaque theme restored" case.
|
||||
readonly property bool ambientModeOn: root.ambientOn && QbzShell.ambientMode === 1
|
||||
readonly property bool blurredModeOn: root.ambientOn && QbzShell.ambientMode === 2
|
||||
|
||||
// Mode 1 — the album-triad metaball field, plus the tunable dark scrim
|
||||
// that keeps chrome text legible over a bright album palette
|
||||
// (QBZ_BG_DIM, default 0.35). The scrim is mode 1 ONLY: mode 2's
|
||||
// atmosphere applies the same dim internally (AppShell.slint:236).
|
||||
AmbientField {
|
||||
anchors.fill: parent
|
||||
visible: root.ambientOn
|
||||
running: root.ambientOn
|
||||
visible: root.ambientModeOn
|
||||
running: root.ambientModeOn
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.ambientOn
|
||||
visible: root.ambientModeOn
|
||||
color: "#000000"
|
||||
opacity: QbzShell.ambientDim
|
||||
}
|
||||
|
||||
// Mode 2 — Blurred art: the SAME ImmersiveAtmosphere the immersive view
|
||||
// and the album/artist headers use, at window size (AppShell.slint:221-231
|
||||
// reuses the identical component). `animated` follows the transport, so a
|
||||
// paused player holds the static pose instead of drifting forever, and the
|
||||
// fallback is the plain cover for a track whose atmosphere bitmap has not
|
||||
// been generated yet.
|
||||
ImmersiveAtmosphere {
|
||||
anchors.fill: parent
|
||||
visible: root.blurredModeOn
|
||||
source: root.blurredModeOn ? QbzImmersive.atmosphereUrl : ""
|
||||
fallbackSource: root.blurredModeOn ? QbzPlayer.npArtworkPath : ""
|
||||
animated: root.blurredModeOn && QbzPlayer.npPlaying
|
||||
dim: QbzShell.ambientDim
|
||||
}
|
||||
|
||||
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
|
||||
property var hostWindow: null
|
||||
|
||||
@@ -233,45 +275,77 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Content frame — the rounded, inset surface-main panel (Radius.md,
|
||||
// 8px gaps left/right/bottom, flush to the header).
|
||||
// Content FRAME — the chrome slab the content pane is inset INTO
|
||||
// (AppShell.slint:357-362). It is the same tier as the sidebar, the header
|
||||
// and the player bar, and under the dynamic background it takes the same
|
||||
// surface-card @ 0.5: the 8px gutter it leaves around the pane is the
|
||||
// "bezel", and it must read as one continuous frame with the sidebar, not
|
||||
// as a bright band of raw background between them.
|
||||
//
|
||||
// It also has to be the pane's PARENT, not its sibling. In the reference
|
||||
// the pane is a child of this rectangle, so surface-main @ 0.22 composites
|
||||
// over card @ 0.5 over the field; as a sibling it composited over the raw
|
||||
// field and the pane came out roughly 1.8x brighter than the reference's.
|
||||
// Measured at y=700 on the owner's capture, Slint's pane is exactly
|
||||
// 0.78 x its gutter (51,48,34 against 65,62,44) — that ratio only falls out
|
||||
// if the gutter is already under the pane.
|
||||
//
|
||||
// With the background OFF this is surface-card, i.e. the same colour the
|
||||
// shell root paints, so the geometry and the look are byte-identical to
|
||||
// what shipped before.
|
||||
Rectangle {
|
||||
id: contentFrame
|
||||
anchors.left: sidebar.right
|
||||
anchors.right: queueColumn.left
|
||||
anchors.top: header.bottom
|
||||
anchors.bottom: npb.top
|
||||
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||
|
||||
// Content PANE — the rounded, inset surface-main panel (Radius.md,
|
||||
// 8px gaps left/right/bottom, flush to the header: the reference gives
|
||||
// the frame no top padding so the pane butts the header band).
|
||||
Rectangle {
|
||||
id: contentPane
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
anchors.bottomMargin: 8
|
||||
radius: theme.radiusMd
|
||||
// Frosted content panel while the ambient background is active
|
||||
// (AppShell.slint: surface-main @ 0.22 + 1px #ffffff@0.10 hairline).
|
||||
// (AppShell.slint: surface-main @ 0.22 + 1px #ffffff@0.10
|
||||
// hairline), over the frame's card @ 0.5.
|
||||
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
|
||||
border.width: root.ambientOn ? 1 : 0
|
||||
border.color: theme.frostBorder
|
||||
clip: true
|
||||
// QBZ_PANE_LAYER: collapse the pane into one cached texture instead
|
||||
// of redrawing its whole (all-alpha, unbatchable) draw list on every
|
||||
// whole-window repaint. Measurement knob, default off — see
|
||||
// settings_qt::pane_layer.
|
||||
layer.enabled: QbzShell.paneLayer
|
||||
|
||||
// The view-mount chain — every route arm, every hard-won comment and
|
||||
// the MyQBZ `kind` discriminator now live in ContentRouter.qml, which
|
||||
// the KIOSK shell mounts too (kiosk-port contract §4.2 / D3). The
|
||||
// Slint kiosk copies AppShell's mount block verbatim and calls that
|
||||
// copy debt in its own header (KioskShell.slint:10-17); this is the
|
||||
// named fix, so the two shells cannot drift.
|
||||
// The view-mount chain — every route arm, every hard-won comment
|
||||
// and the MyQBZ `kind` discriminator now live in ContentRouter.qml,
|
||||
// which the KIOSK shell mounts too (kiosk-port contract §4.2 / D3).
|
||||
// The Slint kiosk copies AppShell's mount block verbatim and calls
|
||||
// that copy debt in its own header (KioskShell.slint:10-17); this
|
||||
// is the named fix, so the two shells cannot drift.
|
||||
//
|
||||
// `kiosk` defaults false, i.e. this instance routes every id to the
|
||||
// desktop view it always did. Reached from elsewhere in this document
|
||||
// through `contentRouter.currentItem` (the multi-select seam above) —
|
||||
// a QML `id` does not cross documents, so `viewLoader` is no longer
|
||||
// nameable here and that public property is the replacement.
|
||||
// desktop view it always did. Reached from elsewhere in this
|
||||
// document through `contentRouter.currentItem` (the multi-select
|
||||
// seam above) — a QML `id` does not cross documents, so
|
||||
// `viewLoader` is no longer nameable here and that public property
|
||||
// is the replacement.
|
||||
ContentRouter {
|
||||
id: contentRouter
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bezel corners ----------------------------------------------------
|
||||
// `clip: true` on contentFrame is a RECTANGULAR scissor in Qt Quick — it
|
||||
// `clip: true` on contentPane is a RECTANGULAR scissor in Qt Quick — it
|
||||
// is a glScissor / QPainter setClipRect and it does NOT follow `radius`.
|
||||
// The radius only shapes that Rectangle's own fill, so anything that
|
||||
// paints opaquely at the frame's corners paints straight over the bezel
|
||||
@@ -287,7 +361,7 @@ Rectangle {
|
||||
// reaches the top edge. The mask therefore belongs here, once, and it
|
||||
// covers every present and future view.
|
||||
//
|
||||
// SIBLINGS of contentFrame, not children of it. Inside the frame they sat
|
||||
// SIBLINGS of the pane, not children of it. Inside it they sat
|
||||
// at z:0 with the view Loader, so any overlay a VIEW mounts painted over
|
||||
// them and the corners read square while it was open — the pane-filling
|
||||
// modals are exactly that: DiscoverConfigModal z:3100 (HomeView:1130),
|
||||
@@ -326,14 +400,16 @@ Rectangle {
|
||||
// an FBO the size of the whole content pane, re-rendered on every window
|
||||
// resize and invalidated by anything animating inside the pane.
|
||||
//
|
||||
// The fill is the shell base (`root.color`) because that is literally
|
||||
// what shows through the bezel: the 8px gutter, the HeaderBar above
|
||||
// and the NPB gap below are all surface-card, and nothing else paints
|
||||
// between `root` and the frame while the corners are on.
|
||||
// The fill is `contentFrame.color` because that is literally what shows
|
||||
// through the bezel — the frame IS the 8px gutter, and with the background
|
||||
// off it is surface-card, the same colour as the HeaderBar above and the
|
||||
// NPB gap below. (It was `root.color` before the frame existed; identical
|
||||
// value, but now it tracks the surface it is actually cutting into rather
|
||||
// than one that happens to match.)
|
||||
//
|
||||
// Gated on the ambient background being OFF, and that is not a
|
||||
// shortcut: with the dynamic background active the frame goes
|
||||
// translucent (surface-main @ 0.22 + hairline) and the ambient field
|
||||
// shortcut: with the dynamic background active the pane goes
|
||||
// translucent (surface-main @ 0.22 + hairline) and the field
|
||||
// is SUPPOSED to show through the corners, so an opaque nub would be
|
||||
// the regression. The same flag also suppresses the album/artist
|
||||
// atmosphere (AlbumView.qml:71 `headerAtmoOn = pref && !ambientOn`,
|
||||
@@ -354,20 +430,27 @@ Rectangle {
|
||||
// parented to the window Overlay, where covering the pane corners is the
|
||||
// correct behaviour. A future full-bleed opaque pane child must round
|
||||
// itself the same way — there is no mask that can do it for it here.
|
||||
BezelCorner { corner: 0; fill: root.color; r: contentFrame.radius
|
||||
// Positions are the PANE's rect in shell coordinates. The pane is a child
|
||||
// of the frame now, so its own x/y are frame-local (8, 0) and have to be
|
||||
// offset by the frame's origin — reading contentPane.x directly here would
|
||||
// put every nub 8px to the left of the corner it is meant to cut.
|
||||
readonly property real _paneX: contentFrame.x + contentPane.x
|
||||
readonly property real _paneY: contentFrame.y + contentPane.y
|
||||
|
||||
BezelCorner { corner: 0; fill: contentFrame.color; r: contentPane.radius
|
||||
visible: !root.ambientOn
|
||||
x: contentFrame.x; y: contentFrame.y }
|
||||
BezelCorner { corner: 1; fill: root.color; r: contentFrame.radius
|
||||
x: root._paneX; y: root._paneY }
|
||||
BezelCorner { corner: 1; fill: contentFrame.color; r: contentPane.radius
|
||||
visible: !root.ambientOn
|
||||
x: contentFrame.x + contentFrame.width - width; y: contentFrame.y }
|
||||
BezelCorner { corner: 2; fill: root.color; r: contentFrame.radius
|
||||
x: root._paneX + contentPane.width - width; y: root._paneY }
|
||||
BezelCorner { corner: 2; fill: contentFrame.color; r: contentPane.radius
|
||||
visible: !root.ambientOn
|
||||
x: contentFrame.x + contentFrame.width - width
|
||||
y: contentFrame.y + contentFrame.height - height }
|
||||
BezelCorner { corner: 3; fill: root.color; r: contentFrame.radius
|
||||
x: root._paneX + contentPane.width - width
|
||||
y: root._paneY + contentPane.height - height }
|
||||
BezelCorner { corner: 3; fill: contentFrame.color; r: contentPane.radius
|
||||
visible: !root.ambientOn
|
||||
x: contentFrame.x
|
||||
y: contentFrame.y + contentFrame.height - height }
|
||||
x: root._paneX
|
||||
y: root._paneY + contentPane.height - height }
|
||||
|
||||
// The bezel nub itself. Kept as an inline component (no outer `id` is
|
||||
// referenced inside it — inline components do not share the document's
|
||||
|
||||
@@ -61,7 +61,7 @@ Rectangle {
|
||||
// surface-card @ 0.5 while the ambient background is active (phase 14,
|
||||
// HeaderBar.slint's with-alpha(app-background-surface-alpha)).
|
||||
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
|
||||
// The host ApplicationWindow (custom chrome); null in previews.
|
||||
@@ -687,7 +687,11 @@ Rectangle {
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
color: theme.surfaceElevated
|
||||
// surface-elevated @ 0.5 under the dynamic background
|
||||
// (HeaderBar.slint:728) — it sits ON the already-translucent header, so
|
||||
// leaving it opaque left one solid slab in the middle of a bar the
|
||||
// field is otherwise showing through.
|
||||
color: root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
|
||||
|
||||
QbzIcon {
|
||||
name: "search"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ Rectangle {
|
||||
// surface-card @ 0.5 while the ambient background is active (phase 14,
|
||||
// PlayerBarSmall.slint).
|
||||
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
|
||||
QbzTheme { id: theme }
|
||||
@@ -212,8 +212,13 @@ Rectangle {
|
||||
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
|
||||
height: parent.height
|
||||
radius: 2
|
||||
color: theme.textMuted
|
||||
opacity: 0.35
|
||||
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
|
||||
theme.textMuted.b, 0.35)
|
||||
// Alpha in the material, not a container `opacity`: an
|
||||
// always-on opacity node pins this quad in its own batch,
|
||||
// and the three seek quads use compatible materials
|
||||
// (QSGSmoothColorMaterial::compare() returns 0), so folding
|
||||
// it lets rail + cache + progress merge.
|
||||
}
|
||||
// Playback progress line.
|
||||
Rectangle {
|
||||
@@ -263,7 +268,9 @@ Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: (root.width - 6) * root.sideFrac
|
||||
clip: true
|
||||
// No clip: the Row sums to exactly this column (width-59 + 19
|
||||
// + 40, spacing 0) and the two time Texts above are now
|
||||
// bounded, which was the only escape path.
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
@@ -318,12 +325,22 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
Text {
|
||||
// Bounded so the column below needs no scissor: a
|
||||
// NaN transient or a >16h duration is the only way
|
||||
// these overflow, and truncating here lands at the
|
||||
// same x the clip did.
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
elide: Text.ElideRight
|
||||
text: root.fmt(QbzPlayer.npElapsedSecs)
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 10
|
||||
color: theme.textMuted
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
elide: Text.ElideRight
|
||||
text: root.fmt(QbzPlayer.npDurationSecs)
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 10
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,7 +26,11 @@ import "../theme"
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
clip: true
|
||||
// Clipped ONLY when stacked with the other panel. The drag ghost really
|
||||
// does leave this rect, but in the single-panel case the panel coincides
|
||||
// exactly with queueColumn, which already clips (AppShell.qml) — so the
|
||||
// scissor is pure duplication there, and duplication costs a batch root.
|
||||
clip: QbzShell.queueOpen && QbzShell.lyricsOpen
|
||||
|
||||
// Queue (0) / History (1) — Slint QueueState.tab.
|
||||
property int tab: 0
|
||||
@@ -378,7 +382,8 @@ Rectangle {
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
// No clip: RoundedImage self-confines. One batch root per
|
||||
// visible queue row.
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.coverMap[row.artUrl] || ""
|
||||
@@ -816,7 +821,7 @@ Rectangle {
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceCard
|
||||
clip: true
|
||||
// No clip: RoundedImage self-confines.
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
|
||||
@@ -1374,10 +1379,14 @@ Rectangle {
|
||||
width: root.width - 2 * theme.spacingMd
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
color: Qt.rgba(theme.surfaceElevated.r, theme.surfaceElevated.g,
|
||||
theme.surfaceElevated.b, 0.95)
|
||||
border.width: 1
|
||||
border.color: theme.accent
|
||||
opacity: 0.95
|
||||
border.color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.95)
|
||||
// Alpha folded into the fill/border instead of a container `opacity`:
|
||||
// an opacity node is its own batch group, and this ghost is live for
|
||||
// the whole drag. Every theme builds these tokens opaque (checked
|
||||
// across crates/qbz-theme/src), so this is the same pixel.
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: theme.spacingSm
|
||||
|
||||
@@ -309,7 +309,7 @@ Rectangle {
|
||||
: theme.sidebarOpenWidth
|
||||
// surface-card @ 0.5 while the ambient background is active (phase 14).
|
||||
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
// Square edges; clip keeps content from spilling while the width
|
||||
// animates (same as the Slint root).
|
||||
|
||||
@@ -108,8 +108,14 @@ Item {
|
||||
width: root.width
|
||||
height: root.width
|
||||
radius: theme.radiusMd
|
||||
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
|
||||
clip: true
|
||||
// The art WELL takes surface-main @ 0.5, the chrome tier — not the
|
||||
// content pane's 0.22 (SidebarNowPlayingDock.slint:193). It shows
|
||||
// only in the frame around a cover that has not loaded, so at 0.22
|
||||
// it read as a hole in the sidebar rather than a recess in it.
|
||||
color: root.ambientOn ? theme.surfaceMainA50 : theme.surfaceMain
|
||||
// No clip: RoundedImage confines itself on both arms; a clip is an
|
||||
// unconditional batch root. The 28x28 eye chip and the centred icon
|
||||
// are strictly inside the square.
|
||||
|
||||
RoundedImage {
|
||||
visible: QbzPlayer.npHasTrack
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || "")
|
||||
|
||||
@@ -33,7 +33,7 @@ Rectangle {
|
||||
// Transparent while the ambient background is active (phase 14 —
|
||||
// HomeView.slint:163: the frosted content panel shows through).
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||
// are rectangular, so the frame's own rounding never reaches the
|
||||
@@ -583,7 +583,9 @@ Rectangle {
|
||||
height: 224
|
||||
radius: 12
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
// No clip: RoundedImage confines itself on both arms; a clip is an
|
||||
// unconditional batch root. coverMenu and CoverLightbox are
|
||||
// view-root siblings, never descendants of this frame.
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
// A custom cover override (shared custom_artwork
|
||||
|
||||
@@ -57,7 +57,7 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -45,7 +45,7 @@ Rectangle {
|
||||
// Transparent while the ambient background is active (phase 14 —
|
||||
// HomeView.slint:163: the frosted content panel shows through).
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||
// are rectangular, so the frame's own rounding never reaches the
|
||||
@@ -2167,7 +2167,12 @@ Rectangle {
|
||||
id: jumpBar
|
||||
width: parent.width - 64
|
||||
padH: 0
|
||||
barBg: root.ambientOn ? "transparent" : theme.surfaceMain
|
||||
// surface-main @ bar-alpha (0.3) under the dynamic
|
||||
// background, NOT transparent: the sticky bar overlays the
|
||||
// page content it scrolls over, so it needs SOME fill to
|
||||
// stay readable — the reference gives thin bars their own
|
||||
// lighter tier for exactly that (ArtistPageView.slint:1108).
|
||||
barBg: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
|
||||
tabs: root.jumpTabs
|
||||
activeTabId: root.activeJumpTab
|
||||
onTabClicked: function (id) { root.scrollToSection(id) }
|
||||
@@ -2684,7 +2689,11 @@ Rectangle {
|
||||
height: Math.max(0, root.height - y)
|
||||
width: root.networkOpen ? 300 : 0
|
||||
clip: true
|
||||
color: theme.surfaceCard
|
||||
// Chrome tier: surface-card @ 0.5 under the dynamic background
|
||||
// (ArtistPageView.slint:1196). Its 44px header row stays TRANSPARENT
|
||||
// there (:1221) — it sits ON this already-translucent body, and a
|
||||
// second translucent layer would compound to near-opaque.
|
||||
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.InOutQuad } }
|
||||
|
||||
Rectangle { anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 1; color: theme.borderSubtle }
|
||||
|
||||
@@ -55,7 +55,7 @@ Rectangle {
|
||||
// Transparent while the ambient background is active — the frosted content
|
||||
// panel shows through (HomeView.qml:53 and its twelve siblings).
|
||||
color: root.ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips are
|
||||
// rectangular, so the frame's rounding never reaches the view — the view's
|
||||
|
||||
@@ -31,7 +31,7 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -58,7 +58,7 @@ Rectangle {
|
||||
// Transparent while the ambient background is active (phase 14 —
|
||||
// HomeView.slint:163: the frosted content panel shows through).
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||
// are rectangular, so the frame's own rounding never reaches the
|
||||
@@ -423,13 +423,17 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cider-style edge fades (Carousel.slint): content dissolves
|
||||
// into the page background at the scrolled edges.
|
||||
// Soft edge fades (Carousel.slint): content dissolves
|
||||
// into the page background at the scrolled edges. Hidden under the
|
||||
// app-wide dynamic background — they fade to the OPAQUE
|
||||
// surface-main, so over a moving field they read as two dark slabs
|
||||
// rather than a dissolve (Carousel.slint:304, :312).
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
visible: !theme.ambientOn
|
||||
opacity: rail.contentX > 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
@@ -443,6 +447,7 @@ Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
visible: !theme.ambientOn
|
||||
opacity: rail.contentX < maxScroll - 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
@@ -1254,6 +1259,17 @@ Rectangle {
|
||||
width: parent.width
|
||||
height: 56
|
||||
|
||||
// Thin-bar tier: surface-main @ bar-alpha (0.3) under the app-wide
|
||||
// dynamic background, opaque surface-main otherwise (HomeView.slint:182).
|
||||
// The toolbar had NO fill of its own — with the background off the
|
||||
// view root's surface-main showed through and it looked right, but
|
||||
// the view root goes transparent under the background, so the bar
|
||||
// lost its backing exactly when it needed one.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
|
||||
}
|
||||
|
||||
Row {
|
||||
// Slint left-controls: x 32 + NavButtons (now a 0px
|
||||
// placeholder) + 16px spacing -> the pill starts at 48.
|
||||
|
||||
@@ -32,7 +32,7 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -47,7 +47,7 @@ Rectangle {
|
||||
readonly property bool mostPlayed: mode === "mostPlayed"
|
||||
readonly property var items: doc.items || []
|
||||
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
// RecentAlbumsView.slint:31 is ambient-aware; MostPlayedAlbumsView
|
||||
// .slint:26 is plain surface-main.
|
||||
color: (!mostPlayed && ambientOn) ? "transparent" : theme.surfaceMain
|
||||
|
||||
@@ -35,7 +35,7 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -56,7 +56,7 @@ Rectangle {
|
||||
// round (the literal in all 14 painting view roots).
|
||||
radius: 12
|
||||
color: root.ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Horizontal album rail (Carousel.slint) — extracted for reuse by the
|
||||
// detail views (AlbumView / ArtistView): section header + page chevrons +
|
||||
// clipped ListView of the shared AlbumCard, with Cider-style edge fades.
|
||||
// clipped ListView of the shared AlbumCard, with soft edge fades.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
@@ -76,11 +76,21 @@ Column {
|
||||
artworkUrl: modelData.artUrl || ""
|
||||
}
|
||||
}
|
||||
// Edge fades — content dissolves into the page background at the
|
||||
// scrolled edges instead of a hard cut. They fade to the OPAQUE
|
||||
// surface-main, so under the app-wide dynamic background they would
|
||||
// paint two dark 56px slabs over the moving field: the reference hides
|
||||
// them outright for exactly that reason (Carousel.slint:304 and :312,
|
||||
// `visible: !ShellState.app-background-active`). This is what put the
|
||||
// black band down the right edge of "From the same artist" in the
|
||||
// owner's 2026-08-04 capture, where the Slint shows the field through
|
||||
// the half-scrolled card.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
visible: !theme.ambientOn
|
||||
opacity: rail.contentX > 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
@@ -94,6 +104,7 @@ Column {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
visible: !theme.ambientOn
|
||||
opacity: rail.contentX < root.maxScroll - 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
|
||||
@@ -96,7 +96,7 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -78,7 +78,7 @@ Rectangle {
|
||||
readonly property string gridId: root.isMix ? "mixtapes" : "collections"
|
||||
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
// A LITERAL 12, as in all 14 painting view roots: QML clips are
|
||||
// RECTANGULAR, so the view must round its own fill or the shell bezel
|
||||
// reads square (AppShell.qml:188-258).
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]),
|
||||
|
||||
Executable
+160
@@ -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"
|
||||
Reference in new issue
Block a user