fix(qbz-qt): unresolvable QML imports, focus-based freezing, and dock render cost

Three views instantiated components the phase-23 directory move had left
unimportable: PlaylistView and SearchView use TrackRow (now in rows/) and
SectionRail uses AlbumCard (now in cards/). QML resolves lazily, so these
survived cargo check, the test suite and the offscreen smoke — they only
failed when the user opened a playlist. Adds a repo-wide audit of every
instantiated component against the importing file's visible directories; the
three above were all of them.

The ambient field and the new dock band froze on lost focus. That is wrong on
any tiling or mosaic desktop, where windows stay fully visible while
unfocused — the field would stop while the user is looking at it. Both now
gate on the window not being minimized or hidden, which is the real "nobody
can see this". Freezing on not-visible stays.

Dock band render cost, in descending order of impact: the drain publishes
only the stream the active mode consumes (Bars mode was marshalling a
512-float waveform QList 30 times a second for nothing); each mode marshals
its QList into JS once per tick instead of once per delegate; one shared
Gradient replaces 28 per-delegate instances; and the per-bar Behaviors are
gone since qbz-audio already smooths and clamps the amplitudes upstream.

18/18 tests; offscreen smoke clean; component-resolution audit clean.

cc
This commit is contained in:
vicrodh committed 2026-07-28 12:26:40 -06:00
1 parent 70bd337d16
commit aabde3be63
7 files changed
+95 -40

No files matched your search

+16 -3
View File
@@ -15,10 +15,16 @@
// - No audio breathe (the POC has no FFT tap; level_smooth = 0 → the
// shader's breathe is a constant 1.0 there anyway) and no grain dither.
// - Rendered with QPainter (renderTarget Image) so it works on the
// software/offscreen path too; 30fps cap + paused when the window is
// inactive (the spec's cost controls §6).
// software/offscreen path too; 30fps cap + paused when the window is not
// being shown (the spec's cost controls §6).
//
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost
// focus. A tiling/mosaic desktop keeps windows visible and unfocused all the
// time — pausing on focus loss would stop the field while the user is
// looking straight at it. Minimized/hidden is the real "nobody can see it".
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
Canvas {
@@ -31,6 +37,13 @@ Canvas {
// Set false by the host to freeze the clock (inactive / hidden).
property bool running: true
// True unless the window is minimized or hidden — see the GATING RULE at
// the top. Focus is deliberately NOT part of this.
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
renderTarget: Canvas.Image
renderStrategy: Canvas.Cooperative
@@ -40,7 +53,7 @@ Canvas {
Timer {
interval: 33
repeat: true
running: root.running && root.visible && root.Window.active
running: root.running && root.visible && root.windowShowing
onTriggered: {
root.t += interval / 1000.0 * 0.75
root.requestPaint()
@@ -17,15 +17,28 @@
// `height - largeDockHeight`. Change the arithmetic in one place and the
// cover slides off the window edge.
//
// PERF (the rule the ambient field already proved): the FFT streams replace
// their QList ~30x/s, so a Repeater bound to QbzViz.bars would rebuild every
// delegate every frame. The Repeaters below use STATIC models (column
// counts) and bind only each bar's HEIGHT — a tick re-evaluates bindings and
// instantiates nothing. Only the ACTIVE mode is instantiated (the modes sit
// behind `active:` Loaders), and the capture tap is gated by vizShouldRun, so
// a hidden band costs zero CPU: the producer parks and the drain thread sleeps.
// PERF — this band redraws 30x/s next to a full-window ambient canvas, so
// every one of these matters:
// 1. The FFT streams REPLACE their QList each tick, so a Repeater bound to
// QbzViz.bars would rebuild every delegate every frame. The Repeaters use
// STATIC models (column counts) and bind only each bar's HEIGHT.
// 2. Each mode caches the list in ONE `var` property per tick. Reading
// QbzViz.bars straight from 28 delegates would marshal the QList into a
// fresh JS array 28 times a frame (48x512 floats for Waveform — the
// expensive one).
// 3. ONE shared Gradient for every bar, not one object per delegate.
// 4. No per-bar Behavior: the producer already smooths and clamps in Rust
// (qbz-audio), so 28 concurrent QML animations would buy nothing.
// 5. Only the ACTIVE mode is instantiated (`active:` Loaders) and viz_qt.rs
// only publishes the stream that mode consumes.
// 6. The capture tap is gated by vizShouldRun — a hidden band costs zero:
// the producer parks and the drain thread sleeps.
//
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost
// focus — a tiling desktop keeps windows visible and unfocused.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
@@ -48,11 +61,17 @@ Item {
// The cover's top edge: below the band when it is shown.
readonly property int artY: padTop + (bandOn ? bandHeight + bandGap : 0)
// True unless the window is minimized or hidden — see the GATING RULE.
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// Capture gate — the Slint AppShell's `viz-should-run`. OFF stops the FFT
// producer outright (viz_qt.rs parks it), so leaving Large or hiding the
// band costs nothing. The transport is mirrored separately (a paused
// player parks the producer via now_playing.rs).
readonly property bool vizShouldRun: root.visible && root.bandOn && root.Window.active
readonly property bool vizShouldRun: root.visible && root.bandOn && root.windowShowing
onVizShouldRunChanged: QbzViz.setEnabled(root.vizShouldRun)
Component.onCompleted: QbzViz.setEnabled(root.vizShouldRun)
Component.onDestruction: QbzViz.setEnabled(false)
@@ -84,6 +103,13 @@ Item {
readonly property color topColor: QbzShell.ambientPrimary
readonly property color bottomColor: QbzShell.ambientSecondary
// ONE gradient instance shared by every bar (perf note 3).
Gradient {
id: barGradient
GradientStop { position: 0.0; color: band.topColor }
GradientStop { position: 1.0; color: band.bottomColor }
}
// Mode 0 — Bars: 28 MIRRORED columns over the 14 ACTIVE bins (the
// 16-bin FFT leaves {1, 15} empty; SpectrumPanel.slint parity).
Loader {
@@ -93,6 +119,8 @@ Item {
id: barsMode
readonly property var activeBins: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
readonly property real slot: band.width / 28
// Marshal the QList into JS ONCE per tick (perf note 2).
readonly property var values: QbzViz.bars
Repeater {
model: 28
delegate: Rectangle {
@@ -101,19 +129,13 @@ Item {
readonly property int bin: index < 14
? barsMode.activeBins[index]
: barsMode.activeBins[27 - index]
readonly property real amp: Math.max(0, Math.min(1, QbzViz.bars[bin] || 0))
readonly property real amp: Math.max(0, Math.min(1, barsMode.values[bin] || 0))
x: index * barsMode.slot + 1
width: barsMode.slot - 2
height: Math.max(2, amp * band.height)
y: band.height - height
radius: 1
gradient: Gradient {
GradientStop { position: 0.0; color: band.topColor }
GradientStop { position: 1.0; color: band.bottomColor }
}
Behavior on height {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
gradient: barGradient
}
}
}
@@ -127,20 +149,20 @@ Item {
sourceComponent: Item {
id: waveMode
readonly property real slot: band.width / 48
// 512 floats — marshalling this per delegate would be the
// single most expensive thing in the dock (perf note 2).
readonly property var values: QbzViz.waveform
Repeater {
model: 48
delegate: Rectangle {
required property int index
readonly property real amp: Math.min(1, Math.abs(QbzViz.waveform[index * 5] || 0))
readonly property real amp: Math.min(1, Math.abs(waveMode.values[index * 5] || 0))
x: index * waveMode.slot + 1
width: waveMode.slot - 2
height: Math.max(2, amp * band.height)
y: (band.height - height) / 2
radius: 1
color: band.topColor
Behavior on height {
NumberAnimation { duration: 70; easing.type: Easing.OutQuad }
}
}
}
}
@@ -153,23 +175,18 @@ Item {
sourceComponent: Item {
id: energyMode
readonly property real slot: band.width / 5
readonly property var values: QbzViz.energy
Repeater {
model: 5
delegate: Rectangle {
required property int index
readonly property real amp: Math.max(0, Math.min(1, QbzViz.energy[index] || 0))
readonly property real amp: Math.max(0, Math.min(1, energyMode.values[index] || 0))
x: index * energyMode.slot + 3
width: energyMode.slot - 6
height: Math.max(2, amp * band.height)
y: band.height - height
radius: 1
gradient: Gradient {
GradientStop { position: 0.0; color: band.topColor }
GradientStop { position: 1.0; color: band.bottomColor }
}
Behavior on height {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
gradient: barGradient
}
}
}