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

+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