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
This commit is contained in:
vicrodh committed 2026-08-22 02:03:10 -06:00
1 parent 7fc7605887
commit 750c59034b
19 files changed
+231 -94

No files matched your search

+1
View File
@@ -629,6 +629,7 @@ fn main() {
"qml/shell/LyricsSyncEngine.qml", "qml/shell/LyricsSyncEngine.qml",
"qml/shell/NavGestureLayer.qml", "qml/shell/NavGestureLayer.qml",
"qml/controls/QbzCheckbox.qml", "qml/controls/QbzCheckbox.qml",
"qml/controls/SelectionModel.qml",
// Library lane (B1-B5): the promoted A-Z strip + the // Library lane (B1-B5): the promoted A-Z strip + the
// per-surface bodies LibraryView.qml was split into. // per-surface bodies LibraryView.qml was split into.
"qml/controls/QbzAlphaStrip.qml", "qml/controls/QbzAlphaStrip.qml",
@@ -0,0 +1,88 @@
// SelectionModel — THE selection rules, in one place.
//
// Five views (Album, Artist, Playlist, Label, LocalAlbum) each carried their
// own hand-copied `selected` map and a `toggleSelected(id)`, and none of them
// knew what a modifier key was: every click was a single toggle, so picking
// 40 tracks meant 40 clicks. Slint and Tauri have had Excel-style selection
// since 2026-06 (crates/qbz/src/selection.rs, src/lib/utils/multiSelect.ts);
// this is that port, with the rule shared instead of copied a sixth time.
//
// ── THE RULES, and where they come from ───────────────────────────────────
// Straight off Tauri's `applyShiftRange`, which is the behaviour that has
// been in the owner's hands the longest:
//
// plain click toggle just this row. It does NOT clear the others — in
// select mode the whole point is accumulating a set.
// Ctrl / Cmd the SAME as a plain click, and that is parity, not a
// shortcut: because a plain click already accumulates,
// "add this one without disturbing the rest" is what both
// already do. Tauri reads only `shiftKey` at click time and
// Slint matched it.
// Shift select the range between the anchor and this row,
// ADDITIVELY — a shift-click never deselects, so re-dragging
// a range cannot eat the set. The anchor does not move, which
// is what lets a range be adjusted by shift-clicking again.
//
// ── THE ANCHOR IS AN ID, NOT AN INDEX ─────────────────────────────────────
// Rows get re-sorted and filtered under a live selection — every one of these
// views has a sort control and Local Library has a filter box. An index
// anchor silently points at a different track the moment the order changes;
// an id is re-resolved against the CURRENT rows on every shift-click, and is
// simply dropped if it is no longer among them. Slint's selection.rs made the
// same call for the same reason.
//
// ── WHY IT RETURNS A MAP INSTEAD OF OWNING ONE ────────────────────────────
// Every host already owns its `selected` map and a dozen call sites read it.
// Taking that ownership away would mean rewriting all of them to find out
// whether the rule is right; handing back the NEXT map keeps each host's
// plumbing untouched and puts only the rule in here. The anchor is the one
// piece of state worth holding, because it is the one nobody had.
//
// A NEW object is always returned: mutating a `var` map in place notifies
// nothing, and every binding on it would go stale.
import QtQuick
QtObject {
id: root
/// The row a shift-range measures from. "" = no anchor yet. Hosts clear
/// it when they leave select mode.
property string anchorId: ""
function _indexOf(rows, id) {
for (var i = 0; i < rows.length; i++)
if (rows[i] && rows[i].id === id)
return i
return -1
}
/// The selection map after clicking `id`, given the ordered `rows` the
/// user is looking at and the `modifiers` off the mouse event.
///
/// Pass the FILTERED rows when a view is filtered: a range over rows the
/// user cannot see is not a range they asked for.
function next(current, id, rows, modifiers) {
var shift = (modifiers & Qt.ShiftModifier) !== 0
var anchorAt = root.anchorId !== "" ? root._indexOf(rows, root.anchorId) : -1
if (shift && anchorAt >= 0) {
var here = root._indexOf(rows, id)
if (here >= 0) {
var lo = Math.min(anchorAt, here)
var hi = Math.max(anchorAt, here)
var m = Object.assign({}, current)
for (var i = lo; i <= hi; i++)
m[rows[i].id] = true
// The anchor STAYS, so the same range can be re-dragged.
return m
}
}
var out = Object.assign({}, current)
if (out[id] === true) delete out[id]
else out[id] = true
root.anchorId = id
return out
}
}
+6 -3
View File
@@ -136,7 +136,10 @@ Rectangle {
/// existing call site is untouched. /// existing call site is untouched.
property bool selectMode: false property bool selectMode: false
property bool checked: false property bool checked: false
signal toggleSelect() /// `modifiers` is the mouse event's, straight through — Shift is what
/// turns a click into a range (controls/SelectionModel.qml). Hosts that
/// do not care can keep a no-argument handler; QML allows it.
signal toggleSelect(int modifiers)
// Per-row artwork placeholder (see the 36px cell below). The host view // Per-row artwork placeholder (see the 36px cell below). The host view
// owns the phase clock so one timer drives every row. // owns the phase clock so one timer drives every row.
property bool artPending: false property bool artPending: false
@@ -677,7 +680,7 @@ Rectangle {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: root.toggleSelect() onClicked: root.toggleSelect(Qt.NoModifier)
} }
} }
Text { Text {
@@ -1473,7 +1476,7 @@ Rectangle {
// In select mode the WHOLE row body is a selection target // In select mode the WHOLE row body is a selection target
// (TrackRow.slint:174) — never a play. // (TrackRow.slint:174) — never a play.
if (root.selectMode) { if (root.selectMode) {
root.toggleSelect() root.toggleSelect(mouse.modifiers)
mouse.accepted = true mouse.accepted = true
return return
} }
+5 -3
View File
@@ -42,7 +42,9 @@ Rectangle {
/// mode only). Default off, so the two catalog call sites are untouched. /// mode only). Default off, so the two catalog call sites are untouched.
property bool selectMode: false property bool selectMode: false
property bool checked: false property bool checked: false
signal toggleSelect() /// `modifiers` rides straight off the mouse event: Shift is what turns
/// a click into a range (controls/SelectionModel.qml).
signal toggleSelect(int modifiers)
QbzTheme { id: theme } QbzTheme { id: theme }
@@ -77,7 +79,7 @@ Rectangle {
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y) rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return return
} }
if (root.selectMode) root.toggleSelect() if (root.selectMode) root.toggleSelect(mouse.modifiers)
else QbzAlbum.openAlbum(root.item.id || "") else QbzAlbum.openAlbum(root.item.id || "")
} }
} }
@@ -138,7 +140,7 @@ Rectangle {
QbzCheckbox { QbzCheckbox {
anchors.centerIn: parent anchors.centerIn: parent
checked: root.checked checked: root.checked
onToggled: root.toggleSelect() onToggled: function (mods) { root.toggleSelect(mods) }
} }
} }
+11 -10
View File
@@ -305,15 +305,16 @@ Rectangle {
readonly property bool multiSelectOn: root.multiSelect readonly property bool multiSelectOn: root.multiSelect
function setMultiSelect(on) { function setMultiSelect(on) {
root.multiSelect = on root.multiSelect = on
if (!on) root.selected = ({}) if (!on) { root.selected = ({}); sel.anchorId = "" }
} }
function toggleSelected(id) { /// Excel-style selection lives in ONE place — controls/SelectionModel.qml
var m = root.selected /// holds the anchor and the Shift-range rule; this view keeps owning its
if (m[id] === true) delete m[id] /// map. `mods` is the mouse event's modifiers, forwarded by the row; a
else m[id] = true /// caller with no event (the checkbox, a keyboard path) may omit it.
// A rebind needs a NEW object reference — mutating in place notifies SelectionModel { id: sel }
// nothing. function toggleSelected(id, mods) {
root.selected = Object.assign({}, m) root.selected = sel.next(root.selected, id, root.visibleTracks,
mods === undefined ? Qt.NoModifier : mods)
} }
/// Selected ids in VISIBLE order — never Object.keys (integer-like keys /// Selected ids in VISIBLE order — never Object.keys (integer-like keys
/// iterate in NUMERIC order, not the user's). /// iterate in NUMERIC order, not the user's).
@@ -332,7 +333,7 @@ Rectangle {
root.selected = m root.selected = m
return return
} }
if (action === "clear") { root.selected = ({}); return } if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
var ids = root.selectedIdsInOrder() var ids = root.selectedIdsInOrder()
if (ids.length === 0) return if (ids.length === 0) return
QbzAlbum.albumBulkAction(albumHeader.id, JSON.stringify(ids), action) QbzAlbum.albumBulkAction(albumHeader.id, JSON.stringify(ids), action)
@@ -1354,7 +1355,7 @@ Rectangle {
downloadGlyph: true downloadGlyph: true
selectMode: root.multiSelect selectMode: root.multiSelect
checked: root.selected[item.id] === true checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id) onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) }
// "Play later" is ON now. It was off because // "Play later" is ON now. It was off because
// `enqueue_album_track` had no block-tail arm — "later" // `enqueue_album_track` had no block-tail arm — "later"
// and "queue" both plain-appended, so the two entries // and "queue" both plain-appended, so the two entries
+18 -14
View File
@@ -827,15 +827,18 @@ Rectangle {
readonly property bool multiSelectOn: root.multiSelect readonly property bool multiSelectOn: root.multiSelect
function setMultiSelect(on) { function setMultiSelect(on) {
root.multiSelect = on root.multiSelect = on
if (!on) root.selected = ({}) if (!on) { root.selected = ({}); sel.anchorId = "" }
} }
function toggleSelected(id) { /// Excel-style selection lives in ONE place — controls/SelectionModel.qml
var m = root.selected /// holds the anchor and the Shift-range rule; this view keeps owning its
if (m[id] === true) delete m[id] /// map. The two lists are CONCATENATED in the order they are drawn, so a
else m[id] = true /// range can run from the last top track into "Appears on" exactly as it
// A rebind needs a NEW object reference — mutating in place notifies /// looks on screen — `selectedIdsInOrder` already walks them that way.
// nothing. SelectionModel { id: sel }
root.selected = Object.assign({}, m) function toggleSelected(id, mods) {
root.selected = sel.next(root.selected, id,
root.topTracks.concat(root.appearsOn),
mods === undefined ? Qt.NoModifier : mods)
} }
function selectedIdsInOrder() { function selectedIdsInOrder() {
var out = [] var out = []
@@ -856,7 +859,7 @@ Rectangle {
root.selected = m root.selected = m
return return
} }
if (action === "clear") { root.selected = ({}); return } if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
var ids = root.selectedIdsInOrder() var ids = root.selectedIdsInOrder()
if (ids.length === 0) return if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "artist", String(artist.id || "")) QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "artist", String(artist.id || ""))
@@ -1049,7 +1052,8 @@ Rectangle {
// (TrackRow.slint:173-177, 392-417). // (TrackRow.slint:173-177, 392-417).
property bool selectMode: false property bool selectMode: false
property bool checked: false property bool checked: false
signal toggleSelect() /// `modifiers` off the mouse event — see SelectionModel.
signal toggleSelect(int modifiers)
// Live offline status for the row (seeded in the document; updates // Live offline status for the row (seeded in the document; updates
// arrive on QbzShell's trackCacheStatusChanged). // arrive on QbzShell's trackCacheStatusChanged).
property int cacheStatus: row.cacheStatus !== undefined ? row.cacheStatus : 0 property int cacheStatus: row.cacheStatus !== undefined ? row.cacheStatus : 0
@@ -1567,7 +1571,7 @@ Rectangle {
onDoubleClicked: if (!popRow.selectMode && !popRow.pulledDead) onDoubleClicked: if (!popRow.selectMode && !popRow.pulledDead)
QbzPlayer.playArtistTrack(row.id) QbzPlayer.playArtistTrack(row.id)
onClicked: { onClicked: {
if (popRow.selectMode) popRow.toggleSelect() if (popRow.selectMode) popRow.toggleSelect(mouse.modifiers)
else mouse.accepted = false else mouse.accepted = false
} }
} }
@@ -2587,7 +2591,7 @@ Rectangle {
showAlbum: true showAlbum: true
selectMode: root.multiSelect selectMode: root.multiSelect
checked: root.selected[row.id] === true checked: root.selected[row.id] === true
onToggleSelect: root.toggleSelected(row.id) onToggleSelect: function (mods) { root.toggleSelected(row.id, mods) }
} }
} }
Item { visible: topTracks.length > root.preview; width: 1; height: 4 } Item { visible: topTracks.length > root.preview; width: 1; height: 4 }
@@ -2684,7 +2688,7 @@ Rectangle {
showAlbum: false showAlbum: false
selectMode: root.multiSelect selectMode: root.multiSelect
checked: root.selected[row.id] === true checked: root.selected[row.id] === true
onToggleSelect: root.toggleSelected(row.id) onToggleSelect: function (mods) { root.toggleSelected(row.id, mods) }
} }
} }
Item { visible: appearsOn.length > root.preview; width: 1; height: 4 } Item { visible: appearsOn.length > root.preview; width: 1; height: 4 }
@@ -2826,7 +2830,7 @@ Rectangle {
showAlbum: true showAlbum: true
selectMode: root.multiSelect selectMode: root.multiSelect
checked: root.selected[row.id] === true checked: root.selected[row.id] === true
onToggleSelect: root.toggleSelected(row.id) onToggleSelect: function (mods) { root.toggleSelected(row.id, mods) }
} }
} }
Item { visible: libraryTab.libAlbums.length > 0; width: 1; height: 24 } Item { visible: libraryTab.libAlbums.length > 0; width: 1; height: 24 }
+11 -10
View File
@@ -96,15 +96,16 @@ Rectangle {
readonly property bool multiSelectOn: root.multiSelect readonly property bool multiSelectOn: root.multiSelect
function setMultiSelect(on) { function setMultiSelect(on) {
root.multiSelect = on root.multiSelect = on
if (!on) root.selected = ({}) if (!on) { root.selected = ({}); sel.anchorId = "" }
} }
function toggleSelected(id) { /// Excel-style selection lives in ONE place — controls/SelectionModel.qml
var m = root.selected /// holds the anchor and the Shift-range rule; this view keeps owning its
if (m[id] === true) delete m[id] /// map. `mods` is the mouse event's modifiers, forwarded by the row; a
else m[id] = true /// caller with no event (the checkbox, a keyboard path) may omit it.
// A rebind needs a NEW object reference — mutating in place notifies SelectionModel { id: sel }
// nothing. function toggleSelected(id, mods) {
root.selected = Object.assign({}, m) root.selected = sel.next(root.selected, id, root.topTracks,
mods === undefined ? Qt.NoModifier : mods)
} }
function selectedIdsInOrder() { function selectedIdsInOrder() {
var rows = root.topTracks var rows = root.topTracks
@@ -121,7 +122,7 @@ Rectangle {
root.selected = m root.selected = m
return return
} }
if (action === "clear") { root.selected = ({}); return } if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
var ids = root.selectedIdsInOrder() var ids = root.selectedIdsInOrder()
if (ids.length === 0) return if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "label", String(doc.id || "")) QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "label", String(doc.id || ""))
@@ -630,7 +631,7 @@ Rectangle {
showAlbum: true showAlbum: true
selectMode: root.multiSelect selectMode: root.multiSelect
checked: root.selected[item.id] === true checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id) onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) }
onPlayRequested: QbzHome.labelPlayTrack(modelData.id) onPlayRequested: QbzHome.labelPlayTrack(modelData.id)
onEnqueueRequested: function (mode) { onEnqueueRequested: function (mode) {
QbzHome.labelEnqueueTrack(modelData.id, mode) QbzHome.labelEnqueueTrack(modelData.id, mode)
+24 -13
View File
@@ -596,17 +596,28 @@ Rectangle {
// A rebind needs a NEW object reference — mutating the map in place // A rebind needs a NEW object reference — mutating the map in place
// notifies nothing (the same rule artMap and every row's `favorite` // notifies nothing (the same rule artMap and every row's `favorite`
// property follow). // property follow).
function toggleTrackSelected(id) { /// Excel-style selection — controls/SelectionModel.qml holds the anchor
var m = root.tracksSelected /// and the Shift-range rule; this view keeps owning its two maps.
if (m[id] === true) delete m[id] ///
else m[id] = true /// TWO models, because tracks and albums are two independent selections
root.tracksSelected = Object.assign({}, m) /// with two independent anchors, and the rows here are MIXED: `visibleRows`
/// carries both kinds, so each range is taken over the visible rows OF ITS
/// OWN KIND. Ranging over the mixed list would let a shift-click on a
/// track sweep up the albums drawn between it and the anchor.
SelectionModel { id: trackSel }
SelectionModel { id: albumSel }
function _rowsOfKind(kind) {
return root.visibleRows.filter(function (r) { return r.kind === kind })
} }
function toggleAlbumSelected(id) { function toggleTrackSelected(id, mods) {
var m = root.albumsSelected root.tracksSelected = trackSel.next(root.tracksSelected, id,
if (m[id] === true) delete m[id] root._rowsOfKind("track"),
else m[id] = true mods === undefined ? Qt.NoModifier : mods)
root.albumsSelected = Object.assign({}, m) }
function toggleAlbumSelected(id, mods) {
root.albumsSelected = albumSel.next(root.albumsSelected, id,
root._rowsOfKind("album"),
mods === undefined ? Qt.NoModifier : mods)
} }
/// Selected ids in VISIBLE order — never `Object.keys(map)`. Qobuz ids are /// Selected ids in VISIBLE order — never `Object.keys(map)`. Qobuz ids are
@@ -630,7 +641,7 @@ Rectangle {
root.tracksSelected = m root.tracksSelected = m
return return
} }
if (action === "clear") { root.tracksSelected = ({}); return } if (action === "clear") { root.tracksSelected = ({}); trackSel.anchorId = ""; return }
var ids = root.selectedIdsInOrder("track", root.tracksSelected) var ids = root.selectedIdsInOrder("track", root.tracksSelected)
if (ids.length === 0) return if (ids.length === 0) return
QbzLibrary.libraryBulkAction("track", JSON.stringify(ids), action) QbzLibrary.libraryBulkAction("track", JSON.stringify(ids), action)
@@ -649,7 +660,7 @@ Rectangle {
root.albumsSelected = m root.albumsSelected = m
return return
} }
if (action === "clear") { root.albumsSelected = ({}); return } if (action === "clear") { root.albumsSelected = ({}); albumSel.anchorId = ""; return }
var ids = root.selectedIdsInOrder("album", root.albumsSelected) var ids = root.selectedIdsInOrder("album", root.albumsSelected)
if (ids.length === 0) return if (ids.length === 0) return
QbzLibrary.libraryBulkAction("album", JSON.stringify(ids), action) QbzLibrary.libraryBulkAction("album", JSON.stringify(ids), action)
@@ -1158,7 +1169,7 @@ Rectangle {
number: modelData._no || (index + 1) number: modelData._no || (index + 1)
selectMode: root.tracksMultiSelect selectMode: root.tracksMultiSelect
checked: root.tracksSelected[modelData.id] === true checked: root.tracksSelected[modelData.id] === true
onToggleSelect: root.toggleTrackSelected(modelData.id) onToggleSelect: function (mods) { root.toggleTrackSelected(modelData.id, mods) }
onPlayRequested: root.playTrackInContext(item.id) onPlayRequested: root.playTrackInContext(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) } onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
// MyQBZ "Add to mixtape" — the HOST builds the // MyQBZ "Add to mixtape" — the HOST builds the
+12 -7
View File
@@ -118,11 +118,16 @@ Rectangle {
// physical copy's. Both invalidate the ids, so the selection goes and // physical copy's. Both invalidate the ids, so the selection goes and
// select mode with it (the same "leaving drops the selection" contract // select mode with it (the same "leaving drops the selection" contract
// `local_bulk::set_select_mode` documents for the tree rail). // `local_bulk::set_select_mode` documents for the tree rail).
onDocChanged: { multiSelect = false; selected = ({}) } onDocChanged: { multiSelect = false; selected = ({}); sel.anchorId = "" }
function toggleSelected(id) { /// Excel-style selection lives in ONE place — controls/SelectionModel.qml
var s = Object.assign({}, selected) /// holds the anchor and the Shift-range rule; this view keeps owning its
if (s[id]) delete s[id]; else s[id] = true /// map. Ranges run over `visibleTracks`, not `tracks`: the search box is a
selected = s /// view filter, and a range over rows the user cannot see is not a range
/// they asked for — the same call `bulkAction`'s select-all already makes.
SelectionModel { id: sel }
function toggleSelected(id, mods) {
selected = sel.next(selected, id, visibleTracks,
mods === undefined ? Qt.NoModifier : mods)
} }
/// Selected ids in the order they are ON SCREEN, so a bulk enqueue lands /// Selected ids in the order they are ON SCREEN, so a bulk enqueue lands
/// in disc/track order. `Object.keys` on the selection map would sort the /// in disc/track order. `Object.keys` on the selection map would sort the
@@ -137,7 +142,7 @@ Rectangle {
return out return out
} }
function bulkAction(action) { function bulkAction(action) {
if (action === "clear") { selected = ({}); return } if (action === "clear") { selected = ({}); sel.anchorId = ""; return }
if (action === "select-all") { if (action === "select-all") {
// What the user can SEE: the search box is a view filter, so // What the user can SEE: the search box is a view filter, so
// "select all" means the filtered set (LocalLibraryView.qml:715). // "select all" means the filtered set (LocalLibraryView.qml:715).
@@ -679,7 +684,7 @@ Rectangle {
onEnqueueRequested: function (m) { onEnqueueRequested: function (m) {
QbzLocal.enqueue("track", trackBlock.modelData.id, m) QbzLocal.enqueue("track", trackBlock.modelData.id, m)
} }
onToggleSelect: root.toggleSelected(trackBlock.modelData.id) onToggleSelect: function (mods) { root.toggleSelected(trackBlock.modelData.id, mods) }
} }
} }
} }
+16 -12
View File
@@ -890,15 +890,19 @@ Rectangle {
function toggleAlbumsMultiSelect() { function toggleAlbumsMultiSelect() {
albumsMultiSelect = !albumsMultiSelect albumsMultiSelect = !albumsMultiSelect
if (!albumsMultiSelect) albumsSelected = ({}) if (!albumsMultiSelect) { albumsSelected = ({}); albumSel.anchorId = "" }
} }
function toggleAlbumSelected(id) { /// Excel-style selection — controls/SelectionModel.qml holds the anchor
var s = Object.assign({}, albumsSelected) /// and the Shift-range rule; this view keeps owning its maps. Ranges run
if (s[id]) delete s[id]; else s[id] = true /// over the VISIBLE rows, because the toolbar's filter is a view filter
albumsSelected = s /// and `select-all` right below already means the filtered set.
SelectionModel { id: albumSel }
function toggleAlbumSelected(id, mods) {
albumsSelected = albumSel.next(albumsSelected, id, albumsVisible,
mods === undefined ? Qt.NoModifier : mods)
} }
function albumsBulkAction(action) { function albumsBulkAction(action) {
if (action === "clear") { albumsSelected = ({}); return } if (action === "clear") { albumsSelected = ({}); albumSel.anchorId = ""; return }
if (action === "select-all") { if (action === "select-all") {
var s = {} var s = {}
for (var i = 0; i < albumsVisible.length; i++) s[albumsVisible[i].id] = true for (var i = 0; i < albumsVisible.length; i++) s[albumsVisible[i].id] = true
@@ -910,15 +914,15 @@ Rectangle {
function toggleTracksMultiSelect() { function toggleTracksMultiSelect() {
tracksMultiSelect = !tracksMultiSelect tracksMultiSelect = !tracksMultiSelect
if (!tracksMultiSelect) tracksSelected = ({}) if (!tracksMultiSelect) { tracksSelected = ({}); trackSel.anchorId = "" }
} }
function toggleTrackSelected(id) { SelectionModel { id: trackSel }
var s = Object.assign({}, tracksSelected) function toggleTrackSelected(id, mods) {
if (s[id]) delete s[id]; else s[id] = true tracksSelected = trackSel.next(tracksSelected, id, tracksVisible,
tracksSelected = s mods === undefined ? Qt.NoModifier : mods)
} }
function tracksBulkAction(action) { function tracksBulkAction(action) {
if (action === "clear") { tracksSelected = ({}); return } if (action === "clear") { tracksSelected = ({}); trackSel.anchorId = ""; return }
if (action === "select-all") { if (action === "select-all") {
var s = {} var s = {}
for (var i = 0; i < tracksVisible.length; i++) s[tracksVisible[i].id] = true for (var i = 0; i < tracksVisible.length; i++) s[tracksVisible[i].id] = true
+11 -10
View File
@@ -80,15 +80,16 @@ Rectangle {
property string loadedPlaylistId: "" property string loadedPlaylistId: ""
function setMultiSelect(on) { function setMultiSelect(on) {
root.multiSelect = on root.multiSelect = on
if (!on) root.selected = ({}) if (!on) { root.selected = ({}); sel.anchorId = "" }
} }
function toggleSelected(id) { /// Excel-style selection lives in ONE place — controls/SelectionModel.qml
var m = root.selected /// holds the anchor and the Shift-range rule; this view keeps owning its
if (m[id] === true) delete m[id] /// map. `mods` is the mouse event's modifiers, forwarded by the row; a
else m[id] = true /// caller with no event (the checkbox, a keyboard path) may omit it.
// A rebind needs a NEW object reference — mutating in place notifies SelectionModel { id: sel }
// nothing. function toggleSelected(id, mods) {
root.selected = Object.assign({}, m) root.selected = sel.next(root.selected, id, root.tracks,
mods === undefined ? Qt.NoModifier : mods)
} }
function selectedIdsInOrder() { function selectedIdsInOrder() {
var rows = root.tracks var rows = root.tracks
@@ -105,7 +106,7 @@ Rectangle {
root.selected = m root.selected = m
return return
} }
if (action === "clear") { root.selected = ({}); return } if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
var ids = root.selectedIdsInOrder() var ids = root.selectedIdsInOrder()
if (ids.length === 0) return if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || "")) QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || ""))
@@ -902,7 +903,7 @@ Rectangle {
zebra: true zebra: true
selectMode: root.multiSelect selectMode: root.multiSelect
checked: root.selected[item.id] === true checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id) onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) }
menuShowRemove: root.isOwner menuShowRemove: root.isOwner
// Reorder chevrons (owner findings 5 + 8). `index` is the // Reorder chevrons (owner findings 5 + 8). `index` is the
// index into `root.tracks`, which under `canReorder` is // index into `root.tracks`, which under `canReorder` is
@@ -128,7 +128,7 @@ Item {
artSource: root.view.artMap[modelData.artKey] || "" artSource: root.view.artMap[modelData.artKey] || ""
selectMode: root.view.albumsMultiSelect selectMode: root.view.albumsMultiSelect
checked: root.view.albumsSelected[modelData.id] === true checked: root.view.albumsSelected[modelData.id] === true
onToggleSelect: root.view.toggleAlbumSelected(modelData.id) onToggleSelect: function (mods) { root.view.toggleAlbumSelected(modelData.id, mods) }
} }
} }
@@ -59,7 +59,9 @@ Item {
signal openRequested(string id) signal openRequested(string id)
signal playRequested(string id) signal playRequested(string id)
signal enqueueRequested(string id, string mode) signal enqueueRequested(string id, string mode)
signal toggleSelect(string id) /// `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 } QbzTheme { id: theme }
@@ -501,7 +503,9 @@ Item {
onEnqueueRequested: function (m) { onEnqueueRequested: function (m) {
root.enqueueRequested(modelData.items[0].id, m) root.enqueueRequested(modelData.items[0].id, m)
} }
onToggleSelect: root.toggleSelect(modelData.items[0].id) onToggleSelect: function (mods) {
root.toggleSelect(modelData.items[0].id, mods)
}
} }
} }
} }
@@ -33,7 +33,9 @@ Rectangle {
signal opened() signal opened()
signal playRequested() signal playRequested()
signal enqueueRequested(string mode) signal enqueueRequested(string mode)
signal toggleSelect() /// `modifiers` rides straight off the mouse event: Shift is what turns
/// a click into a range (controls/SelectionModel.qml).
signal toggleSelect(int modifiers)
QbzTheme { id: theme } QbzTheme { id: theme }
@@ -52,7 +54,7 @@ Rectangle {
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y) rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return return
} }
if (root.selectMode) root.toggleSelect() if (root.selectMode) root.toggleSelect(mouse.modifiers)
else root.opened() else root.opened()
} }
onDoubleClicked: if (!root.selectMode) root.playRequested() onDoubleClicked: if (!root.selectMode) root.playRequested()
@@ -88,7 +90,7 @@ Rectangle {
SelectCheck { SelectCheck {
anchors.centerIn: parent anchors.centerIn: parent
on: root.checked on: root.checked
onToggled: root.toggleSelect() onToggled: function (mods) { root.toggleSelect(mods) }
} }
} }
Rectangle { Rectangle {
@@ -129,7 +129,7 @@ Item {
onOpenRequested: function (id) { root.view.openAlbum(id) } onOpenRequested: function (id) { root.view.openAlbum(id) }
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) } onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) } onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
onToggleSelect: function (id) { root.view.toggleAlbumSelected(id) } onToggleSelect: function (id, mods) { root.view.toggleAlbumSelected(id, mods) }
} }
QbzAlphaStrip { QbzAlphaStrip {
visible: root.view.albumsGroup === "alpha" visible: root.view.albumsGroup === "alpha"
@@ -71,7 +71,9 @@ Item {
property bool zebra: false property bool zebra: false
signal playRequested() signal playRequested()
signal enqueueRequested(string mode) signal enqueueRequested(string mode)
signal toggleSelect() /// `modifiers` rides straight off the mouse event: Shift is what turns
/// a click into a range (controls/SelectionModel.qml).
signal toggleSelect(int modifiers)
QbzTheme { id: theme } QbzTheme { id: theme }
@@ -137,7 +139,7 @@ Item {
// the 14px accent disc the .slint actually specifies. // the 14px accent disc the .slint actually specifies.
selectMode: root.selectMode selectMode: root.selectMode
checked: root.checked checked: root.checked
onToggleSelect: root.toggleSelect() onToggleSelect: function (mods) { root.toggleSelect(mods) }
onPlayRequested: root.playRequested() onPlayRequested: root.playRequested()
onEnqueueRequested: function (m) { root.enqueueRequested(m) } onEnqueueRequested: function (m) { root.enqueueRequested(m) }
} }
@@ -255,7 +255,7 @@ Item {
onEnqueueRequested: function (m) { onEnqueueRequested: function (m) {
QbzLocal.enqueue("track", modelData.row.id, m) QbzLocal.enqueue("track", modelData.row.id, m)
} }
onToggleSelect: root.view.toggleTrackSelected(modelData.row.id) onToggleSelect: function (mods) { root.view.toggleTrackSelected(modelData.row.id, mods) }
} }
} }
} }
@@ -149,6 +149,12 @@ Item {
selectMode: root.view.treeSelectMode selectMode: root.view.treeSelectMode
onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded) onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded)
onActivated: root.view.selectFolder(modelData.path) onActivated: root.view.selectFolder(modelData.path)
// NO Shift-range here, deliberately: this rail's selection
// lives in RUST (QbzLocal.treeToggle*Select owns the set),
// not in a QML map, so controls/SelectionModel.qml has
// nothing to hold an anchor against. Giving the tree ranges
// means teaching the Rust side about an anchor, which is a
// change of its own and not part of this port.
onToggleSelect: { onToggleSelect: {
if (modelData.isFolder) QbzLocal.treeToggleFolderSelect(modelData.path) if (modelData.isFolder) QbzLocal.treeToggleFolderSelect(modelData.path)
else QbzLocal.treeToggleTrackSelect(modelData.path) else QbzLocal.treeToggleTrackSelect(modelData.path)
+4 -2
View File
@@ -27,7 +27,9 @@ Rectangle {
property bool selectMode: false property bool selectMode: false
signal toggled() signal toggled()
signal activated() signal activated()
signal toggleSelect() /// `modifiers` rides straight off the mouse event: Shift is what turns
/// a click into a range (controls/SelectionModel.qml).
signal toggleSelect(int modifiers)
QbzTheme { id: theme } QbzTheme { id: theme }
@@ -93,7 +95,7 @@ Rectangle {
on: root.node.isFolder ? root.node.selectState === 2 on: root.node.isFolder ? root.node.selectState === 2
: root.node.selected === true : root.node.selected === true
partial: root.node.isFolder === true && root.node.selectState === 1 partial: root.node.isFolder === true && root.node.selectState === 1
onToggled: root.toggleSelect() onToggled: function (mods) { root.toggleSelect(mods) }
} }
} }