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/NavGestureLayer.qml",
"qml/controls/QbzCheckbox.qml",
"qml/controls/SelectionModel.qml",
// Library lane (B1-B5): the promoted A-Z strip + the
// per-surface bodies LibraryView.qml was split into.
"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.
property bool selectMode: 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
// owns the phase clock so one timer drives every row.
property bool artPending: false
@@ -677,7 +680,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleSelect()
onClicked: root.toggleSelect(Qt.NoModifier)
}
}
Text {
@@ -1473,7 +1476,7 @@ Rectangle {
// In select mode the WHOLE row body is a selection target
// (TrackRow.slint:174) — never a play.
if (root.selectMode) {
root.toggleSelect()
root.toggleSelect(mouse.modifiers)
mouse.accepted = true
return
}
+5 -3
View File
@@ -42,7 +42,9 @@ Rectangle {
/// mode only). Default off, so the two catalog call sites are untouched.
property bool selectMode: 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 }
@@ -77,7 +79,7 @@ Rectangle {
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return
}
if (root.selectMode) root.toggleSelect()
if (root.selectMode) root.toggleSelect(mouse.modifiers)
else QbzAlbum.openAlbum(root.item.id || "")
}
}
@@ -138,7 +140,7 @@ Rectangle {
QbzCheckbox {
anchors.centerIn: parent
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
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
if (!on) { root.selected = ({}); sel.anchorId = "" }
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
/// Excel-style selection lives in ONE place — controls/SelectionModel.qml
/// holds the anchor and the Shift-range rule; this view keeps owning its
/// map. `mods` is the mouse event's modifiers, forwarded by the row; a
/// caller with no event (the checkbox, a keyboard path) may omit it.
SelectionModel { id: sel }
function toggleSelected(id, mods) {
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
/// iterate in NUMERIC order, not the user's).
@@ -332,7 +333,7 @@ Rectangle {
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzAlbum.albumBulkAction(albumHeader.id, JSON.stringify(ids), action)
@@ -1354,7 +1355,7 @@ Rectangle {
downloadGlyph: true
selectMode: root.multiSelect
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
// `enqueue_album_track` had no block-tail arm — "later"
// and "queue" both plain-appended, so the two entries
+18 -14
View File
@@ -827,15 +827,18 @@ Rectangle {
readonly property bool multiSelectOn: root.multiSelect
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
if (!on) { root.selected = ({}); sel.anchorId = "" }
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
/// Excel-style selection lives in ONE place — controls/SelectionModel.qml
/// holds the anchor and the Shift-range rule; this view keeps owning its
/// map. The two lists are CONCATENATED in the order they are drawn, so a
/// range can run from the last top track into "Appears on" exactly as it
/// looks on screen — `selectedIdsInOrder` already walks them that way.
SelectionModel { id: sel }
function toggleSelected(id, mods) {
root.selected = sel.next(root.selected, id,
root.topTracks.concat(root.appearsOn),
mods === undefined ? Qt.NoModifier : mods)
}
function selectedIdsInOrder() {
var out = []
@@ -856,7 +859,7 @@ Rectangle {
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "artist", String(artist.id || ""))
@@ -1049,7 +1052,8 @@ Rectangle {
// (TrackRow.slint:173-177, 392-417).
property bool selectMode: 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
// arrive on QbzShell's trackCacheStatusChanged).
property int cacheStatus: row.cacheStatus !== undefined ? row.cacheStatus : 0
@@ -1567,7 +1571,7 @@ Rectangle {
onDoubleClicked: if (!popRow.selectMode && !popRow.pulledDead)
QbzPlayer.playArtistTrack(row.id)
onClicked: {
if (popRow.selectMode) popRow.toggleSelect()
if (popRow.selectMode) popRow.toggleSelect(mouse.modifiers)
else mouse.accepted = false
}
}
@@ -2587,7 +2591,7 @@ Rectangle {
showAlbum: true
selectMode: root.multiSelect
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 }
@@ -2684,7 +2688,7 @@ Rectangle {
showAlbum: false
selectMode: root.multiSelect
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 }
@@ -2826,7 +2830,7 @@ Rectangle {
showAlbum: true
selectMode: root.multiSelect
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 }
+11 -10
View File
@@ -96,15 +96,16 @@ Rectangle {
readonly property bool multiSelectOn: root.multiSelect
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
if (!on) { root.selected = ({}); sel.anchorId = "" }
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
/// Excel-style selection lives in ONE place — controls/SelectionModel.qml
/// holds the anchor and the Shift-range rule; this view keeps owning its
/// map. `mods` is the mouse event's modifiers, forwarded by the row; a
/// caller with no event (the checkbox, a keyboard path) may omit it.
SelectionModel { id: sel }
function toggleSelected(id, mods) {
root.selected = sel.next(root.selected, id, root.topTracks,
mods === undefined ? Qt.NoModifier : mods)
}
function selectedIdsInOrder() {
var rows = root.topTracks
@@ -121,7 +122,7 @@ Rectangle {
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "label", String(doc.id || ""))
@@ -630,7 +631,7 @@ Rectangle {
showAlbum: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id)
onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) }
onPlayRequested: QbzHome.labelPlayTrack(modelData.id)
onEnqueueRequested: function (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
// notifies nothing (the same rule artMap and every row's `favorite`
// property follow).
function toggleTrackSelected(id) {
var m = root.tracksSelected
if (m[id] === true) delete m[id]
else m[id] = true
root.tracksSelected = Object.assign({}, m)
/// Excel-style selection — controls/SelectionModel.qml holds the anchor
/// and the Shift-range rule; this view keeps owning its two maps.
///
/// TWO models, because tracks and albums are two independent selections
/// 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) {
var m = root.albumsSelected
if (m[id] === true) delete m[id]
else m[id] = true
root.albumsSelected = Object.assign({}, m)
function toggleTrackSelected(id, mods) {
root.tracksSelected = trackSel.next(root.tracksSelected, id,
root._rowsOfKind("track"),
mods === undefined ? Qt.NoModifier : mods)
}
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
@@ -630,7 +641,7 @@ Rectangle {
root.tracksSelected = m
return
}
if (action === "clear") { root.tracksSelected = ({}); return }
if (action === "clear") { root.tracksSelected = ({}); trackSel.anchorId = ""; return }
var ids = root.selectedIdsInOrder("track", root.tracksSelected)
if (ids.length === 0) return
QbzLibrary.libraryBulkAction("track", JSON.stringify(ids), action)
@@ -649,7 +660,7 @@ Rectangle {
root.albumsSelected = m
return
}
if (action === "clear") { root.albumsSelected = ({}); return }
if (action === "clear") { root.albumsSelected = ({}); albumSel.anchorId = ""; return }
var ids = root.selectedIdsInOrder("album", root.albumsSelected)
if (ids.length === 0) return
QbzLibrary.libraryBulkAction("album", JSON.stringify(ids), action)
@@ -1158,7 +1169,7 @@ Rectangle {
number: modelData._no || (index + 1)
selectMode: root.tracksMultiSelect
checked: root.tracksSelected[modelData.id] === true
onToggleSelect: root.toggleTrackSelected(modelData.id)
onToggleSelect: function (mods) { root.toggleTrackSelected(modelData.id, mods) }
onPlayRequested: root.playTrackInContext(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
// 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
// select mode with it (the same "leaving drops the selection" contract
// `local_bulk::set_select_mode` documents for the tree rail).
onDocChanged: { multiSelect = false; selected = ({}) }
function toggleSelected(id) {
var s = Object.assign({}, selected)
if (s[id]) delete s[id]; else s[id] = true
selected = s
onDocChanged: { multiSelect = false; selected = ({}); sel.anchorId = "" }
/// Excel-style selection lives in ONE place — controls/SelectionModel.qml
/// holds the anchor and the Shift-range rule; this view keeps owning its
/// map. Ranges run over `visibleTracks`, not `tracks`: the search box is a
/// 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
/// in disc/track order. `Object.keys` on the selection map would sort the
@@ -137,7 +142,7 @@ Rectangle {
return out
}
function bulkAction(action) {
if (action === "clear") { selected = ({}); return }
if (action === "clear") { selected = ({}); sel.anchorId = ""; return }
if (action === "select-all") {
// What the user can SEE: the search box is a view filter, so
// "select all" means the filtered set (LocalLibraryView.qml:715).
@@ -679,7 +684,7 @@ Rectangle {
onEnqueueRequested: function (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() {
albumsMultiSelect = !albumsMultiSelect
if (!albumsMultiSelect) albumsSelected = ({})
if (!albumsMultiSelect) { albumsSelected = ({}); albumSel.anchorId = "" }
}
function toggleAlbumSelected(id) {
var s = Object.assign({}, albumsSelected)
if (s[id]) delete s[id]; else s[id] = true
albumsSelected = s
/// Excel-style selection — controls/SelectionModel.qml holds the anchor
/// and the Shift-range rule; this view keeps owning its maps. Ranges run
/// over the VISIBLE rows, because the toolbar's filter is a view filter
/// 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) {
if (action === "clear") { albumsSelected = ({}); return }
if (action === "clear") { albumsSelected = ({}); albumSel.anchorId = ""; return }
if (action === "select-all") {
var s = {}
for (var i = 0; i < albumsVisible.length; i++) s[albumsVisible[i].id] = true
@@ -910,15 +914,15 @@ Rectangle {
function toggleTracksMultiSelect() {
tracksMultiSelect = !tracksMultiSelect
if (!tracksMultiSelect) tracksSelected = ({})
if (!tracksMultiSelect) { tracksSelected = ({}); trackSel.anchorId = "" }
}
function toggleTrackSelected(id) {
var s = Object.assign({}, tracksSelected)
if (s[id]) delete s[id]; else s[id] = true
tracksSelected = s
SelectionModel { id: trackSel }
function toggleTrackSelected(id, mods) {
tracksSelected = trackSel.next(tracksSelected, id, tracksVisible,
mods === undefined ? Qt.NoModifier : mods)
}
function tracksBulkAction(action) {
if (action === "clear") { tracksSelected = ({}); return }
if (action === "clear") { tracksSelected = ({}); trackSel.anchorId = ""; return }
if (action === "select-all") {
var s = {}
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: ""
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
if (!on) { root.selected = ({}); sel.anchorId = "" }
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
/// Excel-style selection lives in ONE place — controls/SelectionModel.qml
/// holds the anchor and the Shift-range rule; this view keeps owning its
/// map. `mods` is the mouse event's modifiers, forwarded by the row; a
/// caller with no event (the checkbox, a keyboard path) may omit it.
SelectionModel { id: sel }
function toggleSelected(id, mods) {
root.selected = sel.next(root.selected, id, root.tracks,
mods === undefined ? Qt.NoModifier : mods)
}
function selectedIdsInOrder() {
var rows = root.tracks
@@ -105,7 +106,7 @@ Rectangle {
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || ""))
@@ -902,7 +903,7 @@ Rectangle {
zebra: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id)
onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) }
menuShowRemove: root.isOwner
// Reorder chevrons (owner findings 5 + 8). `index` is the
// index into `root.tracks`, which under `canReorder` is
@@ -128,7 +128,7 @@ Item {
artSource: root.view.artMap[modelData.artKey] || ""
selectMode: root.view.albumsMultiSelect
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 playRequested(string id)
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 }
@@ -501,7 +503,9 @@ Item {
onEnqueueRequested: function (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 playRequested()
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 }
@@ -52,7 +54,7 @@ Rectangle {
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return
}
if (root.selectMode) root.toggleSelect()
if (root.selectMode) root.toggleSelect(mouse.modifiers)
else root.opened()
}
onDoubleClicked: if (!root.selectMode) root.playRequested()
@@ -88,7 +90,7 @@ Rectangle {
SelectCheck {
anchors.centerIn: parent
on: root.checked
onToggled: root.toggleSelect()
onToggled: function (mods) { root.toggleSelect(mods) }
}
}
Rectangle {
@@ -129,7 +129,7 @@ Item {
onOpenRequested: function (id) { root.view.openAlbum(id) }
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
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 {
visible: root.view.albumsGroup === "alpha"
@@ -71,7 +71,9 @@ Item {
property bool zebra: false
signal playRequested()
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 }
@@ -137,7 +139,7 @@ Item {
// the 14px accent disc the .slint actually specifies.
selectMode: root.selectMode
checked: root.checked
onToggleSelect: root.toggleSelect()
onToggleSelect: function (mods) { root.toggleSelect(mods) }
onPlayRequested: root.playRequested()
onEnqueueRequested: function (m) { root.enqueueRequested(m) }
}
@@ -255,7 +255,7 @@ Item {
onEnqueueRequested: function (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
onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded)
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: {
if (modelData.isFolder) QbzLocal.treeToggleFolderSelect(modelData.path)
else QbzLocal.treeToggleTrackSelect(modelData.path)
+4 -2
View File
@@ -27,7 +27,9 @@ Rectangle {
property bool selectMode: false
signal toggled()
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 }
@@ -93,7 +95,7 @@ Rectangle {
on: root.node.isFolder ? root.node.selectState === 2
: root.node.selected === true
partial: root.node.isFolder === true && root.node.selectState === 1
onToggled: root.toggleSelect()
onToggled: function (mods) { root.toggleSelect(mods) }
}
}