Files
qbz/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml
T
vicrodh 750c59034b feat(qt): Excel-style multi-select — Shift ranges across every surface
Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.

NINE surfaces, not the five the track views suggested: Album, Artist,
Playlist, Label, Local Album, plus Library's tracks and albums and Local
Library's tracks and albums. Each carried its own hand-copied `selected`
map and its own `toggleSelected`, so the rule went into
controls/SelectionModel.qml and the maps stayed where they were — the
smallest change that stops it being copied a tenth time.

The rules are Tauri's `applyShiftRange`, unchanged: a plain click
toggles one row without clearing the rest, Shift fills the range from
the anchor ADDITIVELY (so re-dragging a range cannot eat the set) and
leaves the anchor where it is, and Ctrl/Cmd is the same as a plain click
— not a shortcut, but the behaviour both references already have,
because an accumulating click is what "add without disturbing" means.

Qt makes this CHEAPER than Slint, and the difference is worth recording:
Slint had no modifiers at click time and had to read a thread-local set
from a ModifiersChanged handler, with a documented stale-read edge case
when the window lost focus mid-chord. A QML MouseArea delivers
`mouse.modifiers` on the event itself, so the modifier and the click can
never disagree and no such seam exists here.

Three judgement calls, stated because they are not forced:

  - Library gets TWO models, one per kind. Its rows are mixed, and one
    anchor would let a Shift-click on a track sweep up the albums drawn
    between it and the anchor.
  - Ranges run over the VISIBLE rows. Local Library's search box is a
    view filter, and a range over rows the user cannot see is not the
    range they asked for — the same call `select-all` already made.
  - The Local Library tree rail keeps plain toggles. Its selection lives
    in Rust (QbzLocal.treeToggle*Select), so there is no QML map to hang
    an anchor off; teaching the Rust side about an anchor is its own
    change, and the file says so where a reader would expect otherwise.

The anchor is an ID, not an index, because every one of these views can
re-sort or filter under a live selection — an index anchor silently
points at a different track the moment the order changes. Slint's
selection.rs made the same call for the same reason.

Verified by driving the app: in Abbey Road, click row 1 then Shift-click
row 5 selects exactly rows 1-5 and leaves 6-8 untouched, with the bar
reading "5 selected"; the same in a playlist, additively over a row that
was already selected.

cc
2026-08-22 02:03:10 -06:00

532 lines
27 KiB
QML

// The Local Library album collection — the Qt equivalent of
// discover/AlbumCollectionView.slint as LocalLibraryView.slint mounts it
// (:1255 Albums, :1601 Folders-flat, :2409 Artists detail): grid or list,
// grouped or flat, with the source badge and multi-select.
//
// WHY A CHUNK MODEL AND NOT A GridView + Repeater
// The Slint calls this a "chunked grid" and it is the one surface with the
// documented 16k-row freeze. Here EVERY arm is a single windowing ListView
// over a flat "entry" array:
// { t: 0, label } -> group header (34px)
// { t: 1, items: [...], base: n } -> one visual row of cards, or one
// list row; `base` is the row's
// first index in the flat album array
// so grouping costs one O(n) rebuild when the inputs change (never per
// frame) and the mounted delegate count stays proportional to the viewport,
// not to the library. Artwork is reported from the mounted entry range only,
// via the host's windowing helpers.
import QtQuick
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
import "../../theme"
Item {
id: root
/// The LocalLibraryView root — artMap + queueWindowReport live there.
property var view: null
/// Stable id for this cover surface in the host's window registry. Three
/// instances of this component can be alive at once (Albums, Folders-flat,
/// the Artists detail grid), and they must not share a slot.
property string surface: "collection"
/// Scroll-memory scope for this list ("local:albums" / "local:folders"),
/// empty when the host is not the page (the artist detail pane).
property string scrollScope: ""
/// Flat album rows (already searched/sorted/filtered by the host).
property var rows: []
/// [{ letter, items: [...] }] — only read when `grouped`.
property var groups: []
property bool grouped: false
property string viewMode: "grid" // "grid" | "list"
property bool showSource: true
property bool selectMode: false
/// { albumId: true } — the host owns the selection set.
property var selected: ({})
/// The scrollbar belongs on the WINDOW's right edge, but hosts inset
/// their content (LocalAlbumsTab pads 32, plus 20 more to clear the A-Z
/// strip), which dragged the bar visibly inward compared with the rest of
/// the app. Both are measured from the window's right edge:
/// `scrollBarInset` is where the bar should land (LibraryView uses 4, or
/// 34 with the strip up) and `hostRightInset` is how far this component's
/// own right edge already is from it. Defaults of 0 leave callers that
/// have not opted in exactly where they were.
property int scrollBarInset: 0
property int hostRightInset: 0
signal openRequested(string id)
signal playRequested(string id)
signal enqueueRequested(string id, string mode)
/// `modifiers` rides through from the card's mouse event — Shift is
/// what turns a click into a range (controls/SelectionModel.qml).
signal toggleSelect(string id, int modifiers)
QbzTheme { id: theme }
readonly property int cellW: 220
readonly property int cellH: 266
readonly property int headerH: 34
readonly property int listRowH: 56
/// Columns, or 0 while the width is still UNKNOWN.
///
/// Zero is not a degenerate case to be clamped away — it is the honest
/// answer, and clamping it to 1 is what put a full-length ONE-COLUMN grid
/// on screen at every mount. `Component.onCompleted` builds synchronously
/// (deliberately, so the grid is not empty for a frame), and at that moment
/// `width` is still 0, so `Math.max(1, …)` said "one column" and the whole
/// album list was chunked one-per-row. The real width arrives a moment
/// later, `onColsChanged` fires, and the entire model is thrown away and
/// rebuilt — the reflow the owner sees, and it is visible for exactly as
/// long as chunking every album twice takes.
///
/// A list view really is one column, so that arm keeps its 1.
readonly property int cols: viewMode === "grid"
? (width > 0 ? Math.max(1, Math.floor(width / cellW)) : 0) : 1
/// Stand-in for a grid slot with no album — the last row of a group is
/// rarely full, and since the cell count is now CONSTANT (that is what
/// makes recycling free) those tail slots are real, live cells with no
/// data behind them. `visible: false` does not stop a binding from
/// evaluating, so every `modelData.x` on an empty slot threw a TypeError
/// once per evaluation — a log flood during any scroll. Binding against
/// this frozen object instead keeps the slot silent AND avoids gating the
/// card behind a Loader, which would reintroduce exactly the construction
/// churn the constant cell count removed.
readonly property var emptySlot: ({
"id": "", "title": "", "artist": "", "year": "",
"qualityTier": "", "artKey": "", "source": "", "sourceRaw": ""
})
// ---------------------------------------------------------------------
// Chunk model
// ---------------------------------------------------------------------
// `flat` is the album array in display order (the window report indexes
// into it); `entries` is what the ListView actually mounts.
property var flat: []
property var entries: []
/// [{ letter, index }] with `index` = the ENTRY index of that letter's
/// header — what AlphaStrip needs to scroll here.
property var alphaJumps: []
// Counts and times every rebuild so a smoke run can FALSIFY the coalescing:
// one line per keystroke means it holds, two means it does not. Measured
// around the work itself, never from a deferred callback.
LoggingCategory {
id: colTiming
name: "qbz.nav.timing"
defaultLogLevel: LoggingCategory.Warning
}
property int rebuildCount: 0
function rebuild() {
// No width yet: build NOTHING rather than something guaranteed wrong.
// One empty frame beats a full-length one-column grid that has to be
// discarded — and in the common case the width IS known here, so this
// never fires and the synchronous first build is unchanged.
if (cols <= 0) return
var _t0 = Date.now()
var out = []
var flatOut = []
var jumps = []
var per = cols
var i, j
function pushChunk(items) {
for (j = 0; j < items.length; j += per) {
out.push({ "t": 1, "base": flatOut.length + j,
"items": items.slice(j, j + per) })
}
for (j = 0; j < items.length; j++) flatOut.push(items[j])
}
if (grouped) {
for (i = 0; i < groups.length; i++) {
var g = groups[i]
if (!g.items || g.items.length === 0) continue
jumps.push({ "letter": g.letter, "index": out.length })
// The header carries the flat index its group STARTS at.
// Without it the window report below fell back to 0 whenever
// the first visible entry was a header — so scrolling in
// grouped mode asked for artwork from album 0 through the
// current letter, hundreds of covers at a time, and then
// evicted them again. (Found by an independent review; the
// fallback reads as harmless until you notice what `0` means
// to `queueWindowReport`.)
out.push({ "t": 0, "label": g.letter, "base": flatOut.length })
pushChunk(g.items)
}
} else {
pushChunk(rows)
}
flat = flatOut
entries = out
alphaJumps = jumps
root.rebuildCount += 1
console.info(colTiming, "[coltiming] rebuild #" + root.rebuildCount
+ " surface=" + root.surface + " grouped=" + root.grouped
+ " cols=" + per
+ " albums=" + flatOut.length + " entries=" + out.length
+ " in " + (Date.now() - _t0) + "ms")
report()
}
// `rows` and `groups` are TWO bindings over ONE query: the host derives
// `albumsVisible` from the search box, then `albumsGrouped` FROM
// `albumsVisible` (LocalLibraryView.qml:378-380). So a single keystroke
// changes both, and with one handler each this rebuilt twice — and the
// first of the two was WRONG as well as wasted, because it chunked the new
// rows against the previous groups. Grouping being off does not save it:
// `groupRows` returns a FRESH `[]` every time, which is still a change.
//
// Coalesced into one rebuild per event-loop turn. A zero-interval Timer
// rather than `Qt.callLater`, because dedup then depends on the callback
// being the same function object every time, and `restart()` needs no such
// assumption — same shape as `artFlush` in LocalLibraryView.
Timer {
id: rebuildCoalescer
interval: 0
repeat: false
onTriggered: root.rebuild()
}
function scheduleRebuild() { rebuildCoalescer.restart() }
onRowsChanged: scheduleRebuild()
onGroupsChanged: scheduleRebuild()
onGroupedChanged: scheduleRebuild()
onViewModeChanged: scheduleRebuild()
onColsChanged: scheduleRebuild()
// The first build stays synchronous — deferring it would show one frame of
// empty grid on every mount.
Component.onCompleted: { rebuild(); reportSoon() }
Component.onDestruction: if (view) view.releaseWindow(root.surface)
// ---------------------------------------------------------------------
// Window report — the MOUNTED entry band, mapped back to flat indices.
// ---------------------------------------------------------------------
// TRIGGERS. A report used to leave here on `contentY` and on the model
// swap alone, and BOTH of those fire while this surface is still hidden:
// the tab body is mounted behind `localAlbumsLoading` and the rows land
// in the same pass, so the one report that mattered was thrown away by
// the `!visible` guard below and nothing was requested until the user
// moved the list by a pixel. Every state change that can alter the
// mounted band now reports: mount, becoming visible, the model rebuild,
// a viewport resize, and the scroll.
function report() {
if (!view || !list) return
if (!list.visible || entries.length === 0 || width <= 0) {
view.releaseWindow(root.surface)
return
}
var first = list.indexAt(4, list.contentY + 1)
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(entries.length - 1, first + 8)
var lo = entries[first]
var hi = entries[last]
// Both entry kinds now carry `base`, so a header at either edge of
// the viewport reports ITS group's position instead of collapsing to
// the top of the library.
var loIdx = lo ? (lo.base || 0) : 0
var hiIdx = hi
? (hi.t === 1 ? hi.base + hi.items.length - 1 : Math.max(loIdx, hi.base || 0))
: loIdx
view.queueWindowReport(flat, loIdx, hiIdx, root.surface)
}
/// Report NOW, then again once layout has settled. `indexAt` answers -1
/// until the ListView has laid its delegates out, which is exactly the
/// state a just-mounted / just-shown list is in — the immediate call gets
/// the covers moving off the conservative fallback band, the second one
/// corrects it to the real viewport.
Timer {
id: reportSettle
interval: 50
repeat: false
onTriggered: root.report()
}
function reportSoon() {
report()
reportSettle.restart()
}
onVisibleChanged: {
if (visible) reportSoon()
else if (view) view.releaseWindow(root.surface)
}
onHeightChanged: report()
Connections {
target: root.view
function onArtworkRefresh() { root.reportSoon() }
}
function jumpToEntry(entryIndex) {
list.positionViewAtIndex(entryIndex, ListView.Beginning)
}
ListView {
id: list
anchors.fill: parent
clip: true
cacheBuffer: root.viewMode === "grid" ? root.cellH * 2 : root.listRowH * 10
boundsBehavior: Flickable.StopAtBounds
// Recycle rows instead of destroying and rebuilding them on every
// scroll step. Safe only NOW: the project's own note (PmListRow /
// PmFolderMenu) is that `reuseItems` plus a per-row menu gives every
// pooled delegate its own Popup — so the menus above had to become
// lazy FIRST. In the other order this would install that bug.
reuseItems: true
model: root.entries
onContentYChanged: root.report()
onModelChanged: root.report()
onHeightChanged: root.report()
onVisibleChanged: if (visible) root.reportSoon()
delegate: Loader {
required property var modelData
width: list.width
height: modelData.t === 0 ? root.headerH
: root.viewMode === "grid" ? root.cellH : root.listRowH
sourceComponent: modelData.t === 0 ? headerComp
: root.viewMode === "grid" ? cardRowComp : listRowComp
Component {
id: headerComp
Item {
Text {
x: 2
anchors.verticalCenter: parent.verticalCenter
text: modelData.label
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
}
}
// One visual row of album cards.
Component {
id: cardRowComp
Row {
id: cardRow
/// The row entry from the ListView delegate (the Loader's
/// `modelData`), captured here because the Repeater below
/// shadows that name inside its own delegates.
readonly property var rowEntry: modelData
spacing: root.cellW - 200
// FIXED CELL COUNT — `root.cols`, a NUMBER, not the row's
// item array.
//
// This is what makes the ListView's `reuseItems` actually
// pay. Pooling recycles the row SHELL, but the old
// `model: modelData.items` handed this Repeater a new
// array on every recycle, so it destroyed and rebuilt its
// AlbumCards anyway — and the card is where the cost is:
// ~29 items, a RoundedImage with TWO render targets (its
// own layer plus the mask), image probes, a skeleton.
// The shell was reused and the expensive part was not.
//
// With a constant count the cells are created once and
// survive recycling; a scrolled or re-filtered row only
// reassigns `item`, which is a binding update instead of a
// construction. Same shape as the reference, where cheap
// slots stay alive and only the data moves.
Repeater {
model: root.cols
delegate: Item {
id: cardCell
required property int index
/// The album for this slot, or `null` on the last
/// row when it is not full. A null slot renders
/// nothing and keeps its width, so the row's
/// spacing does not collapse.
readonly property var modelData:
(cardRow.rowEntry && cardRow.rowEntry.items)
? (cardRow.rowEntry.items[cardCell.index] || null)
: null
/// `modelData` answers "is this slot filled?" and
/// stays null so `visible` keeps working. `slot`
/// is what the bindings read: same object when the
/// slot is filled, `root.emptySlot` when it is not.
readonly property var slot:
cardCell.modelData || root.emptySlot
visible: cardCell.modelData !== null
width: 200
height: 246
// Right-click menu for the card (AlbumCard.slint's
// album-menu, local arm: no favourite, no Block —
// the Slint hides Block for source local/plex).
// The card's own ⋯ overlay button opens AlbumCard's
// built-in menu; see the GLUE note about aligning
// that one's entries with these.
// LAZY, like AlbumCard's own. This cell carried a
// SECOND popup on top of the card's, so a grid of
// 1267 local albums paid two Popup constructions
// per cell — and the ListView recycles a screenful
// of cells on every scroll step, which is what the
// owner reports as heavy scrolling here.
Loader {
id: cardMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.openRequested(cardCell.slot.id)
else if (a === "play") root.playRequested(cardCell.slot.id)
else root.enqueueRequested(cardCell.slot.id, a)
}
}
}
/// Build the popup on first use, then open it.
function openCardMenu(anchor, x, y) {
cardMenuLoader.active = true
cardMenuLoader.item.openAtCursor(anchor, x, y)
}
AlbumCard {
localMode: true
albumId: cardCell.slot.id
title: cardCell.slot.title
artist: cardCell.slot.artist
artistId: ""
genre: ""
year: cardCell.slot.year
qualityTier: cardCell.slot.qualityTier
artSource: root.view
? (root.view.artMap[cardCell.slot.artKey] || "") : ""
// LocalLibraryView.slint:1267 `show-source-badge`
// — the card takes the raw source word and the
// BADGE is what the flag switches (blanking
// `source` would also silence the card's
// source-aware menu gates).
//
// `sourceRaw` FIRST (contract §D.1). It carries
// `qobuz_purchase` and nothing else; `source`
// folds that into "offline" so the source chips
// keep filtering it, which means the card's gold
// purchase chip (AlbumCard.qml:588) only ever
// fires off the raw word. All FOUR badge sites
// do this — grid and list must not disagree
// about the same album.
source: cardCell.slot.sourceRaw
|| cardCell.slot.source
showSourceBadge: root.showSource
// SELECT MODE IS THE CARD'S (AlbumCard's
// `selectMode`/`selected`/`selectToggled`, the
// port of discover/AlbumCard.slint:83, :179-197,
// :207, :239, :465). This host used to paint its
// OWN 22px tick on top of the card at top-left —
// a THIRD geometry, and one that could only add
// the indicator: the card's hover PLAY button
// stayed live underneath it, so hovering a card
// you meant to tick and hitting play started the
// album. The card hides the whole hover action
// row and the pin badge in select mode and draws
// the reference's 24px tick in the reference's
// corner (top-right); all three move together,
// which is exactly what a host overlay cannot
// do. Do not re-add one.
selectMode: root.selectMode
selected: root.selected[cardCell.slot.id] === true
onSelectToggled: root.toggleSelect(cardCell.slot.id)
// Non-select mode only — the card routes a
// select-mode click to `selectToggled` and never
// emits this while ticking.
onOpenRequested: root.openRequested(cardCell.slot.id)
onPlayRequested: root.playRequested(cardCell.slot.id)
onEnqueueRequested: function (m) {
root.enqueueRequested(cardCell.slot.id, m)
}
}
// Per-item cover placeholder, handed over to the
// art itself: AlbumCard seals its RoundedImage
// away, so this uses QbzSkeleton's probe arm —
// `coverSource` loads the SAME pixmap-cache entry
// the card is loading and retires the placeholder
// when that decode completes, not when the path
// appears. A bare Rectangle, so it does not take
// pointer events and the card's own areas keep
// working underneath.
// settleMs is mandatory here: local artwork
// resolution drops keys with no cover, so an
// artless album would otherwise shimmer forever
// (see LocalLibraryView.artSettleMs).
QbzSkeleton {
variant: "art"
width: 200
height: 200
pending: root.view
? root.view.artWanted(cardCell.slot.artKey) : false
coverSource: root.view
? root.view.artPathOf(cardCell.slot.artKey) : ""
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
// RIGHT-only, declared after the card so every
// left click still reaches the card's own areas.
MouseArea {
id: cardRc
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: function (mouse) {
cardCell.openCardMenu(cardRc, mouse.x, mouse.y)
}
}
// (The multi-select tick that used to sit here is
// gone — it is AlbumCard's `selectMode` now; see
// the note on the card above.)
}
}
}
}
// One list row.
Component {
id: listRowComp
LocalAlbumRow {
width: list.width
view: root.view
item: modelData.items[0]
artSource: root.view
? (root.view.artMap[modelData.items[0].artKey] || "") : ""
showSource: root.showSource
selectMode: root.selectMode
checked: root.selected[modelData.items[0].id] === true
onOpened: root.openRequested(modelData.items[0].id)
onPlayRequested: root.playRequested(modelData.items[0].id)
onEnqueueRequested: function (m) {
root.enqueueRequested(modelData.items[0].id, m)
}
onToggleSelect: function (mods) {
root.toggleSelect(modelData.items[0].id, mods)
}
}
}
}
}
// Back/forward scroll memory — OPT-IN, because this component is mounted
// by three different hosts (the Albums tab, the Folders tab and the artist
// detail pane) and only the two that ARE the page should own a scroll
// scope. The default "" leaves the third inert.
ScrollMemory { target: list; scope: root.scrollScope }
QbzScrollBar {
anchors.right: parent.right
// Negative when the host insets its content — the bar overhangs to
// reach the real edge. Safe: `root` is a plain Item and does not clip
// (the `clip: true` in this file is on the ListView, a sibling).
anchors.rightMargin: root.scrollBarInset - root.hostRightInset
anchors.top: parent.top
anchors.bottom: parent.bottom
target: list
visible: list.contentHeight > list.height
}
}