Files
qbz/crates/qbz-qt/qml/kiosk/KioskAlbumGrid.qml
T
vicrodh b1dba198c9 feat(qt): kiosk profile release hardening
Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus
22 shared opt-in hosts, shared navigation history with Back/Forward, Albums
as the default library tab, native paged Local Library (artists/tracks),
lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and
artwork sized to the physical frame, static skeletons, 64px transport bar,
Settings touch reflow and a persistent Immersive exit.

Desktop keeps its prior behavior: every shared component defaults to the
desktop path and only opts into Kiosk behavior through explicit properties
(origin-relative ScrollMemory, viewport-bounded artwork requests).

Regression coverage: kiosk navigation and artwork Node tests added to the
runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n
strings for all eight locales.

Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and
1280x800 and a Desktop A/B with seven pixel-identical captures.

cc
2026-09-07 08:23:11 -06:00

263 lines
13 KiB
QML

// KioskAlbumGrid — the wrapping card grid behind the kiosk Search / Library /
// Local Library album, folder and artist tabs.
//
// ── WHY THIS IS NOW A GridView ────────────────────────────────────────────
// It used to be a Flickable holding a Repeater of CHEAP EMPTY SLOTS, one per
// album, each with a Loader that mounted the heavy KioskCard only inside a
// 240 px band around the viewport. Cards and images were bounded that way, and
// the file's own header argued a GridView could not drive the focus writes.
//
// K0 measured what the slots themselves cost, on the real component, at
// 800x480 (03-K0-INITIAL-EVIDENCE.md and 04-INTEGRATION-SEAMS.md):
//
// rows visual items Loaders (top/mid) cards/images assign→settle
// 20 237 44 / 52 12 / 16 168 ms
// 2 000 4 197 2 024 / 2 032 12 / 16 116 ms
// 20 000 40 197 20 024 / 20 032 12 / 16 1 043 ms
//
// and on the Pi itself (Debian 13 aarch64, Qt 6.8.2, software renderer,
// identical fixture) the 20 000-row case took 4 815.99 ms with 40 197 visual
// items. Cards and images were indeed bounded at 12/16 — and the mount still
// cost nearly five seconds on the target hardware, before a single cover.
//
// So the pattern was doing its job and the job was not enough: the O(n) part
// was never the cards, it was the 2n objects the windowing itself needed. That
// is the growth contract §5.1 requires removed and §3.3 answers with
// "ListView/GridView es la primera opción cuando conserva navegación".
//
// A GridView creates delegates for the viewport plus `cacheBuffer` and NOTHING
// for the rest of the model, so 20 rows and 20 000 rows mount the same tree.
//
// ── AND THE FOCUS OBJECTION ───────────────────────────────────────────────
// The retired header was right that hand-written `contentY` arithmetic does
// not survive a view that owns its own origin and margins. The answer is not
// to keep the Repeater, it is to stop doing the arithmetic:
// `positionViewAtIndex(i, GridView.Contain)` scrolls the MINIMUM amount to
// bring an item inside the viewport and does nothing when it is already there
// — which is exactly what the old `scrollFocusIntoView` computed by hand, and
// it is correct through margins, origin and a partially-filled last row.
//
// The Qt.callLater discipline is kept verbatim: the scroll is issued on a
// SETTLED layout, from a function, never from a binding that feeds layout —
// the AlbumCollectionView "Recursion detected" panic class.
//
// ── PUBLIC API (unchanged) ────────────────────────────────────────────────
// `albums`, `columns`, `gap`, `pad`, `open(id)`, plus everything a Flickable
// exposes (`contentY`, `contentHeight`, `flick`…), which a GridView still is.
// `cardW` / `cardH` / `rows` keep their meaning. Callers are unmodified:
// KioskLibrary.qml:437, KioskSearch.qml:696, KioskLocalLibrary.qml:375 and
// :389.
//
// ── NEW: THE VISIBLE WINDOW ───────────────────────────────────────────────
// `firstVisible` / `lastVisible` and the `visibleWindow(first, last)` signal
// exist for the hosts that already own an artwork-window API and could not
// drive it: KioskLocalLibrary reports its window ONCE per tab entry and never
// on scroll (KioskLocalLibrary.qml:161-164), so only the first band of covers
// ever resolves. That limit was inherited from the Slint, whose grid had no
// window hook to fire. This grid has one.
//
// The bounds are DERIVED FROM GEOMETRY (contentY, originY, cellHeight), not
// from walking delegates: it is a handful of integer operations, it is exact
// for a uniform grid, and it does not depend on when the view happens to have
// created a delegate. Emission is coalesced through Qt.callLater and gated on
// an actual change, so a flick emits once per row crossed, not once per frame.
//
// ── OVERSCAN ──────────────────────────────────────────────────────────────
// `overscanRows` (default 1) becomes `cacheBuffer`, i.e. one row of delegates
// kept live above and below the viewport. Contract §5.1 caps grids at two
// rows; the default is deliberately the smaller number because the Pi is the
// floor, and a host with a measurement may raise it to 2.
//
// The content index space skips no entries here; the HOSTING VIEW publishes
// the grid geometry (tabs / columns / count) into QbzKioskNav and this grid
// only mirrors the focus index (ring + scroll-into-view) and answers the Enter
// pulse. `focusedItem` subtracts the view's leading tab entries.
import QtQuick
import com.blitzfc.qbz
GridView {
id: root
property var albums: []
property int columns: 6
property real gap: 16
property real pad: 16
/// Rows of delegates kept alive beyond the viewport, each side. See
/// OVERSCAN above; the contract's ceiling is 2.
property int overscanRows: 1
signal open(string id)
/// Emitted when the range of album indices inside the viewport changes.
/// `first`/`last` are inclusive; both are -1 when the model is empty.
/// Coalesced — see THE VISIBLE WINDOW above.
signal visibleWindow(int first, int last)
signal windowChanged(int first, int last)
// ── pitch ──────────────────────────────────────────────────────────────
// A GridView has no column count: it fits `floor(available / cellWidth)`
// cells per row. So the pitch is derived to make that floor land on
// `columns` exactly.
//
// The old layout was `pad | card | gap | card | … | card | pad`. Read as a
// uniform pitch of `cardW + gap` that is `pad` from the left, the trailing
// cell's own gap covers `gap` of the right padding, so the view only needs
// `pad - gap` of right margin. Substituting back gives exactly the old
// `cardW = (width - 2*pad - (columns-1)*gap) / columns`.
//
// Math.floor, because equality is not safe: `columns * cellWidth` landing a
// float hair ABOVE the available width would fit one column fewer and
// reflow the whole grid. Flooring loses under a pixel per row and cannot.
readonly property real _rightPad: Math.max(0, root.pad - root.gap)
readonly property real _avail: Math.max(0, root.width - root.pad - root._rightPad)
readonly property real cardW: Math.max(0, root.cellWidth - root.gap)
readonly property real cardH: root.cardW + 46
readonly property int rows: root.columns > 0
? Math.ceil(root.albums.length / root.columns)
: 0
model: root.albums
// Never 0: a zero cell size makes the view's own row/column arithmetic
// divide by zero before the item has been given a width.
cellWidth: root.columns > 0 ? Math.max(1, Math.floor(root._avail / root.columns)) : 1
cellHeight: Math.max(1, root.cardH + root.gap)
leftMargin: root.pad
rightMargin: root._rightPad
topMargin: root.pad
bottomMargin: root.pad
clip: true
boundsBehavior: Flickable.StopAtBounds
// THE bound on live delegates. Everything outside viewport + this is not
// instantiated at all — no card, no image, no slot, no Loader.
cacheBuffer: Math.max(0, Math.ceil(root.overscanRows * root.cellHeight))
// Recycle rather than destroy/recreate on every row crossed: a flick over
// a 20 000-row model then costs a fixed number of delegates for its whole
// length. `index`/`modelData` re-evaluate on reuse, so the focus ring and
// the card contents follow.
reuseItems: true
// The focus ring is the kiosk's own (QbzKioskNav), not the view's cursor;
// a highlighted current item would draw a second, contradictory ring.
highlight: null
currentIndex: -1
// Keyboard reaches this grid through QbzKioskNav, never through the view's
// own key handling — two cursors would fight over one ring.
keyNavigationEnabled: false
// ── focus ring + scroll-into-view ──────────────────────────────────────
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& root.focusedItem >= 0
&& root.focusedItem < root.albums.length
/// Scroll the focused card into view. Called through Qt.callLater on a
/// settled layout — never from a binding that feeds layout.
///
/// `GridView.Contain` is the whole of the old hand-written body: scroll the
/// minimum amount to make the item visible, do nothing if it already is.
/// Unlike the arithmetic it replaces it is correct through the view's
/// margins and origin, which is what the previous header was worried about.
function scrollFocusIntoView() {
if (!root.itemFocused)
return
root.positionViewAtIndex(root.focusedItem, GridView.Contain)
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(root.scrollFocusIntoView)
}
// Enter pulse → open the focused album.
function onActivateSeqChanged() {
if (root.itemFocused)
root.open(root.albums[root.focusedItem].id)
}
}
// ── the visible window ─────────────────────────────────────────────────
// `originY` is read rather than assumed: a view with a top margin does not
// put its first row at content y 0, and hardcoding either convention is
// how this kind of arithmetic goes quietly wrong.
function _rowAt(y) {
if (root.cellHeight <= 0)
return 0
return Math.floor((y - root.originY) / root.cellHeight)
}
readonly property int firstVisible: (root.albums.length === 0 || root.columns <= 0)
? -1
: Math.max(0, Math.min(root.albums.length - 1,
Math.max(0, root._rowAt(root.contentY)) * root.columns))
readonly property int lastVisible: (root.albums.length === 0 || root.columns <= 0)
? -1
: Math.max(root.firstVisible, Math.min(root.albums.length - 1,
(Math.max(0, root._rowAt(root.contentY + root.height - 1)) + 1) * root.columns - 1))
/// The band that actually HAS delegates: the visible window widened by
/// `overscanRows` on each side, which is what `cacheBuffer` mounts.
///
/// AN ARTWORK CONSUMER WANTS THIS ONE, not `firstVisible`/`lastVisible`. A
/// card in the overscan is already mounted and already asking for a cover;
/// reporting only the viewport would leave exactly those rows blank at the
/// moment they scroll in, which is the visible half of the "only the first
/// band of covers ever resolves" bug this grid is fixing. The signal
/// carries the viewport pair because that is what "visible window" means;
/// widen with these when reporting keys.
readonly property int firstMounted: root.firstVisible < 0 ? -1
: Math.max(0, root.firstVisible - root.overscanRows * root.columns)
readonly property int lastMounted: root.lastVisible < 0 ? -1
: Math.min(root.albums.length - 1, root.lastVisible + root.overscanRows * root.columns)
property int _emittedFirst: -2
property int _emittedLast: -2
function _emitVisibleWindow() {
if (root.firstVisible === root._emittedFirst && root.lastVisible === root._emittedLast)
return
root._emittedFirst = root.firstVisible
root._emittedLast = root.lastVisible
root.visibleWindow(root.firstVisible, root.lastVisible)
root.windowChanged(root.firstVisible, root.lastVisible)
}
// Coalesced: several of these fire on one flick frame (contentY moves,
// then both bounds recompute), and the host should see one call.
onFirstVisibleChanged: Qt.callLater(root._emitVisibleWindow)
onLastVisibleChanged: Qt.callLater(root._emitVisibleWindow)
// A new model over the same indices is still a new window for the host's
// artwork keys, so the change gate is re-armed rather than skipped.
onAlbumsChanged: {
root._emittedFirst = -2
root._emittedLast = -2
Qt.callLater(root._emitVisibleWindow)
}
Component.onCompleted: Qt.callLater(root._emitVisibleWindow)
delegate: Item {
id: cell
required property var modelData
required property int index
// The cell is the PITCH; the card is the pitch minus the gap, laid at
// the origin, so the gap falls on the right and bottom exactly as the
// absolute layout placed it.
width: root.cellWidth
height: root.cellHeight
KioskCard {
artSize: root.cardW
album: cell.modelData
navFocused: root.itemFocused && root.focusedItem === cell.index
onClicked: function (id) {
root.open(id)
}
}
}
}