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
146 lines
6.7 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|