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

146 lines
6.7 KiB
QML

// LibraryAlbumsList — the Library > Albums tab in LIST mode
// (FavoritesView.slint:1762 `AlbumCollectionView { view-mode: … }` with
// `albums-view-mode == "list"`), plus its multi-select bulk bar (:1734).
//
// REUSE, not a fork: the row IS views/AlbumListRow.qml and the column header
// IS views/AlbumListHeader.qml — the pair built for Discover Browse / Label
// Releases. Two properties were added to the row rather than copying it
// (`artSource`, because this feed is id-keyed through LibraryView's artMap
// instead of carrying a baked `artPath`; and the `selectMode`/`checked`
// /`toggleSelect` trio the reference's LIST-only multi-select needs). Both
// default to the previous behaviour, so the two catalog call sites are
// untouched.
//
// The A-Z jump strip is NOT here: one strip serves grid AND list (and the
// tracks list), so it lives in views/LibraryView.qml next to the surfaces it
// scrolls.
import QtQuick
import com.blitzfc.qbz
import ".."
import "../../controls"
import "../../theme"
Item {
id: root
/// The LibraryView root (artMap, the selection map, the window reporter).
property var view: null
/// The derived album rows for this tab — `view.visibleRows`, passed in so
/// this file never re-derives and both surfaces see the same array.
property var rows: []
QbzTheme { id: theme }
readonly property int rowH: 64
readonly property int rowGap: 4
/// A-Z / index jump target for the host's strip.
function positionAt(index) {
list.positionViewAtIndex(index, ListView.Beginning)
}
Column {
anchors.fill: parent
spacing: 8
QbzMultiSelectBar {
visible: root.view.albumsMultiSelect
width: parent.width
selectedCount: root.view.albumsSelectedCount
// FavoritesView.slint:1737-1743, verbatim. "Make available
// offline" is NOT in the reference's ALBUM bar (only the tracks
// one), so nothing is omitted here.
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
// The .slint asks for `heart-crack`; this port's icon set has
// no such glyph (checked: assets/icons/*/heart*.svg is heart
// and heart-filled only), and a missing name renders NOTHING.
// "heart" + danger:true is what views/local/LocalTracksTab.qml
// already ships for the same action, so the two bars match.
{ "id": "remove-selected", "label": QbzSession.tr("Remove from Library", QbzSession.trRev), "icon": "heart", "danger": true, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true },
]
onAction: function (id) { root.view.albumsBulkAction(id) }
}
Item {
width: parent.width
height: parent.height - (root.view.albumsMultiSelect ? 52 : 0)
AlbumListHeader {
id: header
width: parent.width
selectMode: root.view.albumsMultiSelect
}
ListView {
id: list
anchors.top: header.bottom
anchors.topMargin: 4
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
// No strip inset here: the A-Z strip lives in LibraryView and
// the HOST already widened this component's right margin for
// it. Insetting again would double-count it.
clip: true
spacing: root.rowGap
cacheBuffer: 64 * 8
boundsBehavior: Flickable.StopAtBounds
model: root.rows
// Windowed artwork, same contract as the grid: never read
// `list.model` back off the view (views/LibraryView.qml
// documents the SIGSEGV), always the array from the host.
//
// Gated on visibility like the host's own bodies
// (LibraryView.gridWindowReport / listWindowReport): `artMap`
// is ONE map and a report PRUNES outside its band, while this
// body keeps firing hidden — `rows` flips to [] on every tab
// switch (onModelChanged) and `anchors.fill` turns any window
// resize into onHeightChanged. Ungated it evicted whichever
// body is actually on screen, the Artists sidepanel rail above
// all, whose band is NOT a `visibleRows` band.
function report() {
if (!list.visible) return
var stride = root.rowH + root.rowGap
var first = Math.max(0, Math.floor(list.contentY / stride) - 4)
var last = Math.ceil((list.contentY + list.height) / stride) + 4
root.view.queueWindowReport(first,
Math.min(root.rows.length - 1, last))
}
onContentYChanged: list.report()
onModelChanged: list.report()
onHeightChanged: list.report()
onVisibleChanged: list.report()
Component.onCompleted: list.report()
delegate: AlbumListRow {
required property var modelData
required property int index
width: list.width
item: modelData
rowIndex: index
artSource: root.view.artMap[modelData.artKey] || ""
selectMode: root.view.albumsMultiSelect
checked: root.view.albumsSelected[modelData.id] === true
onToggleSelect: function (mods) { root.view.toggleAlbumSelected(modelData.id, mods) }
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: list.top
anchors.bottom: list.bottom
target: list
visible: list.contentHeight > list.height
}
}
}
}