Files
qbz/crates/qbz-qt/qml/kiosk/KioskDiscover.qml
T
vicrodh e5f93d6805 fix(qt): the stale-derived-read hazard, swept across the kiosk views
KioskArtist's TypeError was found by luck, not by a method, so an independent
agent swept every on<X>Changed handler in the kiosk tree for the same class:
a handler reading a property that is DERIVED from the very thing it fired for.
Three more, all silent:

- KioskDiscover.publishNav() read `navSectionCount` from inside
  onActiveTabChanged, so it published the PREVIOUS tab's section count (and
  NaN on the creation pass). publish() clamps `index` into the count it is
  handed, so a wrong count is a focus ring parked on nothing, not a cosmetic
  slip.
- KioskMyQBZ.publishNav() had the same shape against `cards` and
  onOnCollectionsChanged, counting the previous route's document.
- KioskArtist's shelf skip-cascade read QbzKioskNav.dir inside onIndexChanged.

That last one was MY defect, not the view's: sync() wrote `index` before
`dir`, and each cxx-qt setter emits its NOTIFY synchronously, so the handler
ran while `dir` still held the previous move. The first Down of a session read
dir == 0 and refused to skip, parking the ring on a shelf of zero height; a
direction reversal skipped back the way it came.

Fixed at the source: sync() now writes every CONTEXT field (zone, columns,
count, tabs, shelves, dir, hmove, nav_active) before the two fields consumers
actually react to (index, activate_seq). The QML keeps its Qt.callLater as
well -- that covers Qt's own binding re-evaluation order, which is a different
hazard from the setter order. Two independent guards, because this class is
invisible to every static check.

The general remedy, in all four sites: compute from the source value at call
time, never read a cached derived property inside its own handler. Written up
at qml/Main.qml:233-247 from the window-geometry work.

Gate: 9 warnings = baseline, 291 tests, both audits OK, qmllint clean on every
touched file, kiosk-profile smoke clean, and the shelf skip cascade re-driven
over VNC (scratchpad/kiosk/s-01-skip-cascade.png).

cc
2026-08-03 23:59:42 -06:00

475 lines
23 KiB
QML

// KioskDiscover — lightweight Discover/Home for the kiosk (QML port of
// crates/qbz-ui/ui/shell/KioskDiscover.slint, 261 lines).
//
// Reads the SAME QbzHome documents the desktop HomeView reads (no data copy),
// but renders simple horizontal shelves of KioskCards instead of the heavy
// Carousel/AlbumCard stack — lighter for a Pi. v1 covers the album-carousel
// rows (newReleases, qobuzissimes, editorPicks, pressAwards, idealDiscography,
// mostStreamed); the non-album rows (playlists, mixes, slim rails) are skipped,
// exactly as KioskDiscover.slint:6-8 declares.
//
// Keyboard/gamepad nav: SHELF mode. The nav index space is the 3 tab pills
// (indices 0-2) followed by one index per SECTION DESCRIPTOR — including the
// ones that render nothing (columns == 1, so Up/Down walk shelves). Left/Right
// cannot move the global index (that would jump shelves), so the shell pulses
// `hmove` and the focused shelf consumes it into its LOCAL card cursor. Rows
// that render nothing are skipped by nudging the index one more step in the
// last travel direction (QbzKioskNav.dir), which is why `count` deliberately
// counts every descriptor (KioskDiscover.slint:10-17, :59-83).
//
// THE SKIP PREDICATE IS NOT THE SLINT ONE. Slint tests
// `desc.section.albums.length == 0`, which works only because a non-carousel
// descriptor is built with an EMPTY DiscoverSection (qbz/src/home.rs:856,
// qbz/src/discover_prefs.rs:203-209). The Qt document carries `items` for
// EVERY kind (src/home_qt.rs:127-143), so the equivalent test is
// `kind === "album"` — see isShelf() below.
//
// Slint's `viewport-y` / `viewport-x` are negative as you scroll; Qt's
// contentY/contentX are their positive twins — every occurrence below is
// already translated, and both are WRITTEN from a change handler on a settled
// layout, never read by a binding that feeds layout (the "Recursion detected"
// panic class).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
// KioskDiscover.slint:53 sets no background; the kiosk content panel
// paints surface-main behind it (shell/KioskShell.qml:415).
color: "transparent"
QbzTheme { id: theme }
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
// DiscoverState.active-tab (state.slint:338) is a Slint GLOBAL written by
// HomeActions.select-tab; Qt has neither, and the desktop equivalent is a
// view-local property (views/HomeView.qml:179). The kiosk has THREE tabs —
// HomeView's 4th (recommendations) is not part of this view.
property string activeTab: "home"
// A raw JSON.parse in a binding throws on the pre-publish frame and takes
// the view down; the tree's guarded idiom is views/myqbz/MyQbzGridView.qml:94-103.
function parseSections(doc) {
try {
return JSON.parse(doc)
} catch (e) {
return []
}
}
readonly property var homeSections: root.parseSections(QbzHome.homeSectionsJson)
readonly property var editorSections: root.parseSections(QbzHome.editorSectionsJson)
readonly property var forYouSections: root.parseSections(QbzHome.forYouSectionsJson)
// KioskDiscover.slint:146-150.
//
// A FUNCTION behind the property, because `publishNav()` is called from
// `onActiveTabChanged` and must not read the property there — see the
// note on publishNav below.
function sectionsFor(tab) {
return tab === "editorPicks"
? root.editorSections
: tab === "forYou"
? root.forYouSections
: root.homeSections
}
readonly property var activeSections: root.sectionsFor(root.activeTab)
/// Does this descriptor render a shelf? Two terms, and the For You gate.
///
/// `kind === "album"` is the Qt translation of the Slint empty-section test
/// (see the header). The For You term reproduces a property of the
/// reference that the Qt documents would otherwise break: Slint's
/// foryou_descriptors (qbz/src/discover_prefs.rs:218-225) maps EVERY
/// enabled id through bare_descriptor, so every For You descriptor carries
/// an empty section and the Slint kiosk's For You body is BLANK — the pills
/// stay focusable, the page below them is empty. The Qt For You document
/// does carry kind:"album" sections with items (src/home_qt.rs:512, :522,
/// :545, :555), so without this term the port would show content where the
/// reference shows none.
function isShelf(sec) {
return root.activeTab !== "forYou"
&& sec !== undefined && sec !== null
&& sec.kind === "album"
&& (sec.items || []).length > 0
}
// ---- Nav geometry publish (KioskDiscover.slint:59-83): 3 tab pills + one
// index per descriptor. `count` counts EVERY descriptor, including the ones
// that render nothing — that is precisely what the skip cascade exists for.
// The index clamp lives inside publishNav (src/kiosk_nav_qt.rs:266-274).
readonly property int navSectionCount: root.activeSections.length
// Derives the count from `activeTab` AT CALL TIME rather than reading
// `navSectionCount`, and that is load-bearing. `onActiveTabChanged` below
// is connected to the very notify that dirties `activeSections` — and
// through it `navSectionCount` — and the handler runs BEFORE the dependent
// bindings re-evaluate, so a read of `navSectionCount` from inside it
// carries the PREVIOUS tab's descriptor count (undefined on the creation
// pass, which makes `count` NaN). `publish()` CLAMPS `index` into the
// count it is handed (src/kiosk_nav_qt.rs:266-274), so a wrong count is a
// wrong focus ring, not a cosmetic slip. Same hazard, same remedy, as
// KioskArtist.qml:102-110 and the note at Main.qml:233-247.
function publishNav() {
QbzKioskNav.publishNav(3, 1, 3 + root.sectionsFor(root.activeTab).length, true)
}
Component.onCompleted: root.publishNav()
onNavSectionCountChanged: root.publishNav()
onActiveTabChanged: root.publishNav()
// Enter on a tab pill → the same tab switch the pills' taps drive
// (KioskDiscover.slint:85-98). The `index < tabs` test is what keeps this
// watcher and the shelves' disjoint: both see the same pulse.
Connections {
target: QbzKioskNav
function onActivateSeqChanged() {
if (!QbzKioskNav.navActive || QbzKioskNav.zone !== "content"
|| QbzKioskNav.index >= QbzKioskNav.tabs)
return
if (QbzKioskNav.index === 0)
root.activeTab = "home"
else if (QbzKioskNav.index === 1)
root.activeTab = "editorPicks"
else if (QbzKioskNav.index === 2)
root.activeTab = "forYou"
}
}
// KioskDiscover.slint:23-51. Height 40, Radius.sm, 16px side padding, the
// label 15px/600, and a 2px accent ring when the shell focus lands here.
component TabPill: Rectangle {
id: pill
property string label: ""
property bool active: false
property bool navFocused: false
signal picked()
// The pre-publish frame carries no alpha ramp, where alphaTier()
// silently returns "transparent" (theme/QbzTheme.qml:97-99); the
// literal is the Slint dark value of Theme.alpha-6 and its
// light-polarity mirror (idiom + reason: rows/TrackRow.qml:264-269).
readonly property color hoverFill: theme.alphaTiers.length > 0
? theme.alphaTier(6) : (theme.isDark ? "#0fffffff" : "#0f000000")
width: pillLabel.implicitWidth + 32
height: 40
radius: theme.radiusSm
color: pill.active ? theme.accent
: (pillArea.containsMouse ? pill.hoverFill : "transparent")
border.width: pill.navFocused ? 2 : 0
border.color: theme.accent
Text {
id: pillLabel
anchors.centerIn: pill
text: pill.label
// KioskDiscover.slint:40 is Theme.accent-text on the accent fill;
// on an accent FILL this port takes the owner-approved glyph
// selector instead (theme/QbzTheme.qml:111-203, :314-317), because
// white/accent-text fails 3:1 on 16 of 35 palettes.
color: pill.active ? theme.accentGlyphColor : theme.textSecondary
font.pixelSize: 15
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: pillArea
anchors.fill: pill
hoverEnabled: true
onClicked: pill.picked()
}
}
Flickable {
id: pageFlick
anchors.fill: root
contentWidth: pageFlick.width
contentHeight: page.height + 32 // the column's 16px top+bottom pad
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
x: 16
y: 16
width: pageFlick.width - 32
spacing: 22
// Tab pills — Home / Editor's Picks / For You
// (KioskDiscover.slint:109-142). The container hugs the pill row.
Rectangle {
width: pills.width + 10 // the row's 5px padding, both sides
height: 50
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
Row {
id: pills
x: 5
y: 5
spacing: 4
TabPill {
label: root.trs("Home")
active: root.activeTab === "home"
navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === 0
onPicked: root.activeTab = "home"
}
TabPill {
label: root.trs("Editor's Picks")
active: root.activeTab === "editorPicks"
navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === 1
onPicked: root.activeTab = "editorPicks"
}
TabPill {
label: root.trs("For You")
active: root.activeTab === "forYou"
navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === 2
onPicked: root.activeTab = "forYou"
}
}
}
// One block per descriptor of the active tab. The Repeater walks
// the RAW array — a descriptor that renders nothing still owns its
// nav index and still carries the skip handler below, which is how
// the cascade walks contiguous gaps (KioskDiscover.slint:146-258).
Repeater {
model: root.activeSections
delegate: Item {
id: shelf
required property var modelData
required property int index
readonly property bool isShelf: root.isShelf(shelf.modelData)
readonly property var items: shelf.isShelf ? (shelf.modelData.items || []) : []
// The focused card INSIDE this shelf.
property int cursor: 0
readonly property bool shelfFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index - QbzKioskNav.tabs === shelf.index
width: page.width
// 24px title + 12 + 182px strip. A descriptor that renders
// nothing collapses to 0, and the column's 22px spacing
// still separates it — the Slint VerticalLayout does the
// same with its empty loop item.
height: shelf.isShelf ? 218 : 0
/// Skip over descriptors that render nothing: take one more
/// step in the last travel direction. The step re-fires
/// every shelf's handler, so the cascade walks contiguous
/// gaps and converges (KioskDiscover.slint:158-170).
///
/// The Slint original assigns `KioskNavState.index`
/// directly. Here the step goes through navDown()/navUp()
/// instead, because `QbzKioskNav.index` is a one-way MIRROR
/// of the Rust model (src/kiosk_nav_bridge.rs:109-110,
/// :148-163 sync()): a QML write moves the ring for one
/// frame and the next arrow key resyncs it back off the
/// untouched model — i.e. one dead keypress per skipped
/// row. The movement functions keep the model authoritative,
/// and for a shelf view (columns 1) they take exactly the
/// same +1 / -1 step inside the item range
/// (src/kiosk_nav_qt.rs:184-214, :153-181). At the two ends
/// the movement functions would LEAVE the content zone
/// (navDown() to the player bar, navUp() to the tab strip),
/// where Slint's `Math.max(tabs, Math.min(count - 1, ...))`
/// parks on the empty edge descriptor with no ring visible
/// — spec S2 §7.3 pins that termination as source behavior
/// the port must preserve. Hence the edge guards below:
/// step only while the step stays inside [tabs, count-1],
/// park otherwise.
///
/// Deferred, not immediate: the step is taken from inside
/// the model's own change notification, and re-entering the
/// bridge there would run a nested sync() inside the
/// outer one (:148-163 reads a clone of the model up front).
function skipIfEmpty() {
if (QbzKioskNav.zone !== "content" || !QbzKioskNav.navActive
|| QbzKioskNav.index - QbzKioskNav.tabs !== shelf.index
|| shelf.isShelf || QbzKioskNav.dir === 0)
return
if (QbzKioskNav.dir > 0) {
if (QbzKioskNav.index < QbzKioskNav.count - 1)
QbzKioskNav.navDown()
} else if (QbzKioskNav.index > QbzKioskNav.tabs) {
QbzKioskNav.navUp()
}
}
/// Keep the focused shelf's strip inside the page viewport.
/// Page coords are FIXED ARITHMETIC: 16px pad + 50px tab
/// row + 22px spacing = 88, then a 240px pitch per block
/// (24 title + 12 + 182 strip + 22 spacing), block height
/// 218 (KioskDiscover.slint:193-208). The literals are the
/// source's: they assume every descriptor renders, so a
/// preceding empty one shifts the real geometry — that
/// approximation is part of the reference and is reproduced
/// rather than corrected.
function scrollShelfIntoView() {
if (!shelf.shelfFocused || shelf.items.length === 0)
return
var top = 88 + shelf.index * 240
if (top < pageFlick.contentY)
pageFlick.contentY = top - 8
else if (top + 218 > pageFlick.contentY + pageFlick.height)
pageFlick.contentY = top + 218 - pageFlick.height + 8
}
/// Keep the cursor card inside the strip's horizontal
/// viewport; card pitch = 128px art + 14px spacing
/// (KioskDiscover.slint:232-243). At cursor 0 after a
/// scroll the first arm yields contentX = -8, an 8px left
/// gutter — the source's behaviour, kept (StopAtBounds is
/// what stops Qt animating it away).
function scrollCursorIntoView() {
if (!shelf.shelfFocused)
return
var left = shelf.cursor * 142
if (left < stripFlick.contentX)
stripFlick.contentX = left - 8
else if (left + 128 > stripFlick.contentX + stripFlick.width)
stripFlick.contentX = left + 128 - stripFlick.width + 8
}
onCursorChanged: Qt.callLater(shelf.scrollCursorIntoView)
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(shelf.skipIfEmpty)
Qt.callLater(shelf.scrollShelfIntoView)
}
// Left/Right shelf move: the shell pulses `hmove` and
// the focused shelf consumes it into its cursor.
// takeHmove() reads and resets in one step, so a pulse
// can never be applied twice (src/kiosk_nav_qt.rs:279-281);
// an unfocused or empty shelf must NOT consume it
// (KioskDiscover.slint:172-182).
function onHmoveChanged() {
if (!shelf.shelfFocused || QbzKioskNav.hmove === 0
|| shelf.items.length === 0)
return
var pulse = QbzKioskNav.takeHmove()
shelf.cursor = Math.max(0,
Math.min(shelf.cursor + pulse, shelf.items.length - 1))
}
// Enter pulse → open the cursor album
// (KioskDiscover.slint:184-191).
function onActivateSeqChanged() {
if (shelf.shelfFocused && shelf.items.length > 0
&& shelf.cursor < shelf.items.length)
QbzAlbum.openAlbum(shelf.items[shelf.cursor].id)
}
}
Column {
id: block
visible: shelf.isShelf
width: shelf.width
spacing: 12
// Fixed-height title wrapper (KioskDiscover.slint:213-225):
// it is what makes the block a constant 218px, so the
// arithmetic above stays exact.
//
// The label is CENTRED, both axes. Not a choice: the
// Slint Text sits in a plain Rectangle, and a non-layout
// parent centres a child that does not fill it
// (i-slint-compiler passes/default_geometry.rs,
// maybe_center_in_parent) — a bare Text in the
// VerticalLayout, which the comment at :213 says this
// replaced, would have been left-aligned.
Item {
id: titleWrap
width: block.width
height: 24
Text {
id: titleText
anchors.centerIn: titleWrap
text: shelf.modelData.title || ""
color: theme.textPrimary
font.pixelSize: 18
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
}
Flickable {
id: stripFlick
width: block.width
height: 182
contentWidth: strip.width
contentHeight: stripFlick.height
clip: true
boundsBehavior: Flickable.StopAtBounds
Row {
id: strip
spacing: 14
Repeater {
model: shelf.items
delegate: KioskCard {
id: card
required property var modelData
required property int index
// artSize stays the primitive's 128
// default, which is what makes the
// 142px pitch above right.
//
// KioskCard reads a RESOLVED cover off
// `album.artwork`; the home documents
// carry the cache path as `artPath`
// (src/home_qt.rs:119-120), the same
// field views/HomeView.qml:385 hands
// its cards.
album: ({
id: card.modelData.id,
title: card.modelData.title,
artist: card.modelData.artist,
artwork: card.modelData.artPath || "",
qualityTier: card.modelData.qualityTier
})
navFocused: shelf.shelfFocused && card.index === shelf.cursor
onClicked: function (id) {
QbzAlbum.openAlbum(id)
}
}
}
}
}
}
}
}
}
}
}