fix(qt): a selection anchor finds numeric ids, and ranges skip header rows

controls/SelectionModel.qml kept the anchor in a string property and then
looked it up with a strict comparison, so a list whose rows carry numeric
ids never found its anchor again and every Shift-click there degraded to a
plain toggle. Ids now compare as strings, which is what the selection map
keys already were.

The range fill also wrote `m[rows[i].id]` for every row in the span, so a
list that mixes group headers into its rows gained an "undefined" key and a
count one above the ticks on screen. Rows without an id are skipped.

`idKey` names the identifying field for rows that do not call it `id`.

scripts/qml-tests/tst_multi_select.qml starts with the rule's own cases and
joins qt-runtime-gate.sh and test-crates.yml.

cc
This commit is contained in:
vicrodh committed 2026-09-14 10:30:18 -06:00
1 parent 49c5d64fc0
commit 7d1a144c89
5 files changed
+118 -13

No files matched your search

+46 -13
View File
@@ -40,6 +40,20 @@
//
// A NEW object is always returned: mutating a `var` map in place notifies
// nothing, and every binding on it would go stale.
//
// ── EVERY CLICK TARGET MUST HAND ITS MODIFIERS OVER ───────────────────────
// The rule only sees what the click target passes in. A select-mode surface
// has more targets than its row body — the checkbox disc, the checkbox
// controls, a whole album card, the name links drawn on a row — and each one
// that called its host with no modifiers turned every Shift-click there into
// a plain toggle. `scripts/qml-tests/tst_multi_select.qml` clicks all of them
// through the real components.
//
// ── IDS COMPARE AS STRINGS ────────────────────────────────────────────────
// `anchorId` is a string property, so a numeric id (an offline track id, a
// settings folder id) was stored as "42" and then never found again among
// rows carrying 42 — Shift silently degraded to a toggle. The selection map
// keys are strings anyway, so comparing as strings is the same identity.
import QtQuick
@@ -49,40 +63,59 @@ QtObject {
/// The row a shift-range measures from. "" = no anchor yet. Hosts clear
/// it when they leave select mode.
property string anchorId: ""
/// The row field that identifies a row. Track and album rows use `id`;
/// the offline manager's rows carry `trackId`, the explorer facets `key`.
property string idKey: "id"
function _idOf(row) {
if (!row)
return ""
var value = row[root.idKey]
return value === undefined || value === null ? "" : String(value)
}
function _indexOf(rows, id) {
var wanted = String(id)
for (var i = 0; i < rows.length; i++)
if (rows[i] && rows[i].id === id)
if (root._idOf(rows[i]) === wanted)
return i
return -1
}
/// Adds every identified row between the anchor and `id` to `into`.
/// Rows without an id (group headers riding in the same list) are skipped.
function _fillRange(into, rows, anchorAt, here) {
var lo = Math.min(anchorAt, here)
var hi = Math.max(anchorAt, here)
for (var i = lo; i <= hi; i++) {
var rowId = root._idOf(rows[i])
if (rowId !== "")
into[rowId] = true
}
return into
}
/// 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 shift = ((modifiers || 0) & 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
if (here >= 0)
// The anchor STAYS, so the same range can be re-dragged.
return m
}
return root._fillRange(Object.assign({}, current), rows, anchorAt, here)
}
var key = String(id)
var out = Object.assign({}, current)
if (out[id] === true) delete out[id]
else out[id] = true
root.anchorId = id
if (out[key] === true) delete out[key]
else out[key] = true
root.anchorId = key
return out
}
}