Files
qbz/crates/qbz-qt/qml/controls/SelectionModel.qml
T
vicrodh ad07d8c14e feat(qt): Library Explorer columns select a run with Shift
A facet column already took Ctrl as "add or remove one" and a plain click
as "only this". Shift now picks the run from the last clicked value and
Ctrl+Shift adds that run, the file-list rule (SelectionModel.nextExclusive),
each column over its values as listed. A column whose values are rebuilt by
a change upstream starts without an anchor.

cc
2026-09-14 10:31:48 -06:00

157 lines
7.3 KiB
QML

// 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.
//
// ── 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
QtObject {
id: root
/// 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 (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 || 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)
// The anchor STAYS, so the same range can be re-dragged.
return root._fillRange(Object.assign({}, current), rows, anchorAt, here)
}
var key = String(id)
var out = Object.assign({}, current)
if (out[key] === true) delete out[key]
else out[key] = true
root.anchorId = key
return out
}
/// The spreadsheet / file-manager rule, for lists where a plain click
/// REPLACES the selection (the Library Explorer facets):
///
/// plain click only this row; it becomes the anchor
/// Ctrl / Cmd toggle this row, keep the rest; it becomes the anchor
/// Shift only the range anchor..row; the anchor stays
/// Ctrl+Shift add the range anchor..row to the selection
///
/// Without a visible anchor Shift behaves like the same click without it.
function nextExclusive(current, id, rows, modifiers) {
var mods = modifiers || 0
var shift = (mods & Qt.ShiftModifier) !== 0
var additive = (mods & (Qt.ControlModifier | Qt.MetaModifier)) !== 0
var key = String(id)
var anchorAt = root.anchorId !== "" ? root._indexOf(rows, root.anchorId) : -1
if (shift && anchorAt >= 0) {
var here = root._indexOf(rows, id)
if (here >= 0)
return root._fillRange(additive ? Object.assign({}, current) : {},
rows, anchorAt, here)
}
root.anchorId = key
if (!additive) {
var only = {}
only[key] = true
return only
}
var out = Object.assign({}, current)
if (out[key] === true) delete out[key]
else out[key] = true
return out
}
}