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:
19 files changed
+231
-94
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user