From 750c59034b04cfb5010fffdc033e245b776f7c93 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:03:10 -0600 Subject: [PATCH] =?UTF-8?q?feat(qt):=20Excel-style=20multi-select=20?= =?UTF-8?q?=E2=80=94=20Shift=20ranges=20across=20every=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/controls/SelectionModel.qml | 88 +++++++++++++++++++ crates/qbz-qt/qml/rows/TrackRow.qml | 9 +- crates/qbz-qt/qml/views/AlbumListRow.qml | 8 +- crates/qbz-qt/qml/views/AlbumView.qml | 21 ++--- crates/qbz-qt/qml/views/ArtistView.qml | 32 ++++--- crates/qbz-qt/qml/views/LabelView.qml | 21 ++--- crates/qbz-qt/qml/views/LibraryView.qml | 37 +++++--- crates/qbz-qt/qml/views/LocalAlbumView.qml | 19 ++-- crates/qbz-qt/qml/views/LocalLibraryView.qml | 28 +++--- crates/qbz-qt/qml/views/PlaylistView.qml | 21 ++--- .../qml/views/library/LibraryAlbumsList.qml | 2 +- .../qml/views/local/LocalAlbumCollection.qml | 8 +- .../qbz-qt/qml/views/local/LocalAlbumRow.qml | 8 +- .../qbz-qt/qml/views/local/LocalAlbumsTab.qml | 2 +- .../qbz-qt/qml/views/local/LocalTrackRow.qml | 6 +- .../qbz-qt/qml/views/local/LocalTracksTab.qml | 2 +- .../qbz-qt/qml/views/local/LocalTreeRail.qml | 6 ++ crates/qbz-qt/qml/views/local/TreeRow.qml | 6 +- 19 files changed, 231 insertions(+), 94 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/SelectionModel.qml diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index dc33562d7..66b1c3b2b 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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", diff --git a/crates/qbz-qt/qml/controls/SelectionModel.qml b/crates/qbz-qt/qml/controls/SelectionModel.qml new file mode 100644 index 000000000..5d6d38460 --- /dev/null +++ b/crates/qbz-qt/qml/controls/SelectionModel.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 + } +} diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 8c48414d9..2e6dec35a 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml index 6e7999657..99f0c106d 100644 --- a/crates/qbz-qt/qml/views/AlbumListRow.qml +++ b/crates/qbz-qt/qml/views/AlbumListRow.qml @@ -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) } } } diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index b168030b9..8ce6a6e49 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index 3939c8e99..7095fc6d4 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -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 } diff --git a/crates/qbz-qt/qml/views/LabelView.qml b/crates/qbz-qt/qml/views/LabelView.qml index 952fa9dd9..b70810f5e 100644 --- a/crates/qbz-qt/qml/views/LabelView.qml +++ b/crates/qbz-qt/qml/views/LabelView.qml @@ -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) diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 4bb7f5098..ea8ebeff6 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/LocalAlbumView.qml b/crates/qbz-qt/qml/views/LocalAlbumView.qml index 22d80169b..14ea7d4f5 100644 --- a/crates/qbz-qt/qml/views/LocalAlbumView.qml +++ b/crates/qbz-qt/qml/views/LocalAlbumView.qml @@ -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) } } } } diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index bd66bcdc0..d3d825c06 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index bd9099691..7ce9cd04d 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml b/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml index 2c150834f..1c9d9b7d8 100644 --- a/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml +++ b/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml @@ -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) } } } diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml index 3ad4ec4fe..e87387596 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml @@ -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) + } } } } diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml b/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml index caac6e0e5..228a76115 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml @@ -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 { diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml b/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml index 10888d79c..32d1933f3 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml @@ -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" diff --git a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml index 4ea3ce5dd..2c27aaee6 100644 --- a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml +++ b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml @@ -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) } } diff --git a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml index 88f0f421b..e996a1a20 100644 --- a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml @@ -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) } } } } diff --git a/crates/qbz-qt/qml/views/local/LocalTreeRail.qml b/crates/qbz-qt/qml/views/local/LocalTreeRail.qml index 5621051a8..4e9b32957 100644 --- a/crates/qbz-qt/qml/views/local/LocalTreeRail.qml +++ b/crates/qbz-qt/qml/views/local/LocalTreeRail.qml @@ -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) diff --git a/crates/qbz-qt/qml/views/local/TreeRow.qml b/crates/qbz-qt/qml/views/local/TreeRow.qml index 638d682bd..6fff7fa07 100644 --- a/crates/qbz-qt/qml/views/local/TreeRow.qml +++ b/crates/qbz-qt/qml/views/local/TreeRow.qml @@ -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) } } }