Files
qbz/crates/qbz-qt/qml/views/local/LocalTracksTab.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

359 lines
17 KiB
QML

// Tracks tab body (LocalLibraryView.slint:1317) — the SERVER-PAGINATED flat
// list. This is the surface that froze the Slint build at 16k tracks: the
// model is never the whole table (500 rows per page, appended within 600px
// of the tail — the Slint's own trigger distance at :1442) and the ListView
// only ever instantiates its window.
//
// Grouping (off / album / artist / name) inserts 32px header rows, so the
// model is an entry array — { t: 0, label } | { t: 1, row, n } — exactly as
// the collection does it, keeping ONE windowing ListView with variable row
// heights instead of a Repeater per group.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
/// The tab's own left/right breathing room. Named because the scrollbar
/// has to CANCEL it (see the QbzScrollBar at the bottom of the list) —
/// with the number written twice, moving one would silently misalign it.
readonly property int sideInset: 32
property var view: null
QbzTheme { id: theme }
readonly property int rowH: 50
readonly property int headerH: 32
// --------------------------- entry model ------------------------------
property var entries: []
property var alphaJumps: []
/// entry index -> index into `view.tracksVisible`, -1 for a group header.
/// Group headers inflate the entry indices, so reporting them straight
/// into the row array asked for the covers of tracks BELOW the ones on
/// screen, by the number of headers scrolled past.
property var rowIndex: []
function rebuild() {
var rows = view ? view.tracksVisible : []
var mode = view ? view.tracksGroup : "off"
var out = []
var jumps = []
var idx = []
var prev = null
for (var i = 0; i < rows.length; i++) {
var t = rows[i]
var key = mode === "album" ? (t.album || "")
: mode === "artist" ? (t.artist || "")
: mode === "name" ? (t.title || "").slice(0, 1).toUpperCase()
: ""
if (mode !== "off" && key !== "" && key !== prev) {
if (mode === "name") jumps.push({ "letter": key, "index": out.length })
out.push({ "t": 0, "label": key })
idx.push(-1)
prev = key
}
out.push({ "t": 1, "row": t, "n": i + 1 })
idx.push(i)
}
entries = out
alphaJumps = jumps
rowIndex = idx
report()
}
Component.onCompleted: { rebuild(); reportSoon() }
Component.onDestruction: if (view) view.releaseWindow("tracks")
Connections {
target: root.view
function onTracksVisibleChanged() { root.rebuild() }
function onTracksGroupChanged() { root.rebuild() }
// The row artwork column is OFF by default; turning it on has to ask
// for the covers the rows are already bound to.
function onTrackArtworkChanged() { root.reportSoon() }
function onArtworkRefresh() { root.reportSoon() }
}
// --------------------------- row play ---------------------------------
// PARITY-DEBT #14. Rust used to queue `state.tracks_raw` — the raw SQL
// page order — and find the clicked row in it, so with a group mode on
// (a CLIENT-side reorder, see LocalLibraryView.tracksVisible) the user
// heard a different order than the one on screen. The visible order only
// exists QML-side, so it goes down as a JSON id array and the raw rows
// play the part of the authoritative cache
// (local_playback::order_by_visible, the local twin of
// library_qt::order_by_visible).
//
// `view.tracksVisible` is THE array `entries` is built from — never read
// `list.model` back off the view (see views/LibraryView.qml).
function visibleTrackIds() {
var rows = root.view ? root.view.tracksVisible : []
var out = []
for (var i = 0; i < rows.length; i++) out.push(rows[i].id)
return out
}
function playRow(id) {
QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id)
}
// First page = the shape of the 50px track rows (the Slint mounts a bare
// 36px LoadingSpinner). ONE instance for the whole viewport.
QbzSkeleton {
variant: "rowList"
anchors.fill: parent
anchors.leftMargin: root.sideInset
anchors.rightMargin: root.sideInset
anchors.topMargin: 12
visible: QbzLocal.localTracksLoading
rowH: root.rowH
rowGap: 0
rowArt: root.view.trackArtwork
rowArtSize: 36
phase: root.view.skelPhase
}
LocalNote {
visible: !QbzLocal.localTracksLoading && root.view.tracks.length === 0
&& root.view.tracksSearch === ""
text: QbzSession.tr("No tracks in your local library yet.", QbzSession.trRev)
}
LocalNote {
visible: !QbzLocal.localTracksLoading
&& (root.view.tracks.length === 0 || root.view.tracksVisible.length === 0)
&& root.view.tracksSearch !== ""
text: QbzSession.tr("No tracks match your search.", QbzSession.trRev)
}
Column {
anchors.fill: parent
anchors.leftMargin: root.sideInset
anchors.rightMargin: root.sideInset
anchors.topMargin: 12
spacing: 8
visible: !QbzLocal.localTracksLoading && root.view.tracksVisible.length > 0
QbzMultiSelectBar {
visible: root.view.tracksMultiSelect
width: parent.width
selectedCount: root.view.tracksSelectedCount
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
// NO "Remove from favorites": this tab is the whole local
// library, not a favourites surface — removing from a
// collection the user is not looking at is not an action this
// context can offer. (It was doubly wrong: local hearts are
// unwired, so `local_bulk.rs`'s arm is a log-only no-op.)
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true },
]
onAction: function (id) { root.view.tracksBulkAction(id) }
}
Item {
width: parent.width
height: parent.height - (root.view.tracksMultiSelect ? 52 : 0)
ListView {
id: list
anchors.fill: parent
anchors.rightMargin: root.view.tracksGroup === "name" ? 20 : 0
clip: true
cacheBuffer: 50 * 12
boundsBehavior: Flickable.StopAtBounds
model: root.entries
onContentYChanged: {
root.report()
// Infinite scroll: 600px of runway (Slint :1442).
//
// `contentHeight > height` states the precondition the
// predicate always had: a list shorter than its viewport
// has no bottom to run out of, so it cannot be "600px from
// the end". Nothing is lost by it — a list that short
// cannot scroll (StopAtBounds), so contentY never moves
// and this handler never runs for it.
//
// It is ALSO the guard for this handler's one re-entrant
// entry: it runs from INSIDE QQuickItemView::setModel()
// (the same window that makes a `.model` readback segfault
// — see views/LibraryView.qml), where contentY has been
// reset but the layout has not been redone.
//
// MEASURED, so nobody re-hunts it: that entry does NOT in
// fact misfire under qml6 6.11.1. contentHeight inside the
// swap is the PREVIOUS model's value, not 0 — checked over
// empty->long, short->long, long->short, long->empty,
// same-length republishes, both with forceLayout and on
// the real async polish path. Every one of them entered
// the handler with (contentY 0, contentHeight stale-LARGE)
// and evaluated FALSE. So this line changes no observed
// behaviour today; it is kept because it is true and free,
// and because the only shape that would misfire is the one
// it covers.
if (contentHeight > height
&& contentY + height >= contentHeight - 600
&& QbzLocal.localTracksHasMore
&& !QbzLocal.localTracksLoadingMore
&& !QbzLocal.localTracksLoading) {
QbzLocal.tracksLoadMore()
}
}
onModelChanged: root.report()
onHeightChanged: root.report()
onVisibleChanged: {
if (visible) root.reportSoon()
else if (root.view) root.view.releaseWindow("tracks")
}
delegate: Loader {
required property var modelData
width: list.width
height: modelData.t === 0 ? root.headerH : root.rowH
sourceComponent: modelData.t === 0 ? headerComp : rowComp
Component {
id: headerComp
Item {
Text {
x: 2
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 4
text: modelData.label
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
}
}
Component {
id: rowComp
LocalTrackRow {
width: list.width
view: root.view
item: modelData.row
// The row artwork column was rendering a
// permanently blank 36px cell: local track rows
// ship an EMPTY artPath (local_rows.rs:284) and
// nothing ever fed LocalTrackRow.artSource. It is
// the same id-keyed artMap every other local
// surface reads.
artSource: root.view.artMap[modelData.row.artKey] || ""
number: modelData.n
// Album column hides when the list is already
// grouped by album (Slint :1486).
showAlbum: root.view.tracksGroup !== "album"
showArtwork: root.view.trackArtwork
selectMode: root.view.tracksMultiSelect
checked: root.view.tracksSelected[modelData.row.id] === true
onPlayRequested: root.playRow(modelData.row.id)
onEnqueueRequested: function (m) {
QbzLocal.enqueue("track", modelData.row.id, m)
}
onToggleSelect: function (mods) { root.view.toggleTrackSelected(modelData.row.id, mods) }
}
}
}
// Infinite scroll: the NEXT page in the shape it will arrive
// in (3 rows), appended below the last real row. Each
// placeholder is replaced by a real row as the page lands.
footer: Item {
width: list.width
height: QbzLocal.localTracksLoadingMore ? 3 * root.rowH : 0
QbzSkeleton {
variant: "rowList"
anchors.fill: parent
visible: QbzLocal.localTracksLoadingMore
rowH: root.rowH
rowGap: 0
rowArt: root.view.trackArtwork
rowArtSize: 36
phase: root.view.skelPhase
}
}
}
QbzAlphaStrip {
visible: root.view.tracksGroup === "name" && root.alphaJumps.length > 0
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
jumps: root.alphaJumps
onJump: function (ordinal, index) {
list.positionViewAtIndex(index, ListView.Beginning)
}
}
// Back/forward scroll memory (controls/ScrollMemory.qml).
ScrollMemory { target: list; scope: "local:tracks" }
QbzScrollBar {
anchors.right: parent.right
// The bar belongs on the VIEW edge, not on the content edge.
// Its parent sits inside the tab's 32px inset, so a plain 4px
// margin put the bar 36px from the window — a visibly wide
// empty channel with nothing in it (owner, 2026-08-16). The
// negative margin cancels the inset and leaves the same 4px
// gap the other lists have; the ROWS keep their inset.
//
// The "name" arm is untouched on purpose: there the alpha
// strip owns the right edge at the inset, and 22 is what
// clears it. Pushing the bar out there would jump it past the
// strip.
anchors.rightMargin: root.view.tracksGroup === "name"
? 22 : 4 - root.sideInset
anchors.top: parent.top
anchors.bottom: parent.bottom
target: list
visible: list.contentHeight > list.height
}
}
}
// TRIGGERS: `contentY` + the model swap alone never fired for a list that
// was still hidden behind `localTracksLoading` when its first page landed
// — the covers only started resolving on the first scroll. Mount, the
// rebuild, becoming visible and a resize report too.
function report() {
if (!view || !list) return
// The row artwork column is gated on the appearance setting (default
// OFF, for the 16K-track freeze). With it off nothing on this list
// renders a cover, so nothing is worth decoding.
if (!list.visible || !view.trackArtwork || root.entries.length === 0) {
view.releaseWindow("tracks")
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(root.entries.length - 1, first + 12)
// Entry band -> row band (group headers carry no cover).
var lo = -1
var hi = -1
for (var i = first; i <= last; i++) {
var n = root.rowIndex[i]
if (n === undefined || n < 0) continue
if (lo < 0) lo = n
hi = n
}
if (lo < 0) { lo = 0; hi = Math.min(view.tracksVisible.length - 1, 12) }
view.queueWindowReport(view.tracksVisible, Math.max(0, lo - 4), hi + 4, "tracks")
}
/// Report now, then again once the ListView has laid out (`indexAt`
/// answers -1 until then — the state a just-shown list is in).
Timer {
id: reportSettle
interval: 50
repeat: false
onTriggered: root.report()
}
function reportSoon() {
report()
reportSettle.restart()
}
}