From 7d1a144c891aa35f037ddeae4a0b2046737b310b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:30:18 -0600 Subject: [PATCH 01/11] 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 --- .github/workflows/test-crates.yml | 2 + crates/qbz-qt/qml/controls/SelectionModel.qml | 59 +++++++++++++++---- scripts/qml-tests/tst_multi_select.qml | 48 +++++++++++++++ scripts/qt-runtime-gate.sh | 1 + scripts/test-multi-select-qml.sh | 21 +++++++ 5 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 scripts/qml-tests/tst_multi_select.qml create mode 100755 scripts/test-multi-select-qml.sh diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index 106774c6b..633a33692 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -88,6 +88,7 @@ on: - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" - "scripts/test-keyed-model-qml.sh" + - "scripts/test-multi-select-qml.sh" - "scripts/test-wallpaper-field-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" @@ -120,6 +121,7 @@ on: - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" - "scripts/test-keyed-model-qml.sh" + - "scripts/test-multi-select-qml.sh" - "scripts/test-wallpaper-field-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" diff --git a/crates/qbz-qt/qml/controls/SelectionModel.qml b/crates/qbz-qt/qml/controls/SelectionModel.qml index 5d6d38460..8551010ed 100644 --- a/crates/qbz-qt/qml/controls/SelectionModel.qml +++ b/crates/qbz-qt/qml/controls/SelectionModel.qml @@ -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 } } diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml new file mode 100644 index 000000000..9f4948566 --- /dev/null +++ b/scripts/qml-tests/tst_multi_select.qml @@ -0,0 +1,48 @@ +// Excel-style multi-select regression suite: the shared selection rule +// (controls/SelectionModel.qml) and the REAL row, card, checkbox and rail +// components of the select-mode surfaces. Every click target a select-mode +// surface offers must deliver Shift (a range from the anchor) and Ctrl (one +// more row) to the selection; a target that swallows the modifier, or that +// navigates away instead of selecting, is exactly the regression this guards. +import QtQuick +import QtTest +import com.blitzfc.qbz +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 1200; height: 1400 + + // ---- the selection rule itself --------------------------------------------- + SelectionModel { id: rule } + SelectionModel { id: keyedRule; idKey: "trackId" } + TestCase { + name: "SelectionRule" + + function init() { + rule.anchorId = "" + keyedRule.anchorId = "" + } + + function test_numeric_ids_range() { + var rows = [{ "id": 11 }, { "id": 12 }, { "id": 13 }, { "id": 14 }] + var m = rule.next({}, 11, rows, Qt.NoModifier) + m = rule.next(m, 13, rows, Qt.ShiftModifier) + compare(Object.keys(m).sort().join(","), "11,12,13") + } + + function test_rows_without_id_are_skipped() { + var rows = [{ "id": "a" }, { "kind": "header" }, { "id": "b" }] + var m = rule.next({}, "a", rows, Qt.NoModifier) + m = rule.next(m, "b", rows, Qt.ShiftModifier) + compare(Object.keys(m).sort().join(","), "a,b") + } + + function test_id_key() { + var rows = [{ "trackId": 7 }, { "trackId": 8 }, { "trackId": 9 }] + var m = keyedRule.next({}, 9, rows, Qt.NoModifier) + m = keyedRule.next(m, 7, rows, Qt.ShiftModifier) + compare(Object.keys(m).sort().join(","), "7,8,9") + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index f845cea3b..235a89e26 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -11,6 +11,7 @@ bash scripts/test-compact-seek-qml.sh bash scripts/test-library-folders-qml.sh bash scripts/test-array-model-qml.sh bash scripts/test-keyed-model-qml.sh +bash scripts/test-multi-select-qml.sh bash scripts/test-wallpaper-field-qml.sh bash scripts/test-card-menu-qml.sh bash scripts/test-orbit-qml.sh diff --git a/scripts/test-multi-select-qml.sh b/scripts/test-multi-select-qml.sh new file mode 100755 index 000000000..e71ae720b --- /dev/null +++ b/scripts/test-multi-select-qml.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Excel-style multi-select regression: every select-mode click target — row +# body, checkbox disc, checkbox controls, album cards, the name links on a +# row, the My QBZ rows, the folder rail and the settings folder table — hands +# Shift / Ctrl to the selection rule, and the rule itself ranges over string +# and numeric ids alike. Same run in CI and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_multi_select_bins="$QT_ROOT_DIR/bin" +else + qt_multi_select_qmake="${QMAKE:-}" + if [[ -z "$qt_multi_select_qmake" ]]; then + qt_multi_select_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_multi_select_bins="$("$qt_multi_select_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_multi_select_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_multi_select.qml \ + -import scripts/qml-tests/imports From 7c9bf8043faa073cd78641dd7cee8056b828895d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:30:34 -0600 Subject: [PATCH 02/11] fix(qt): Shift and Ctrl work on every select-mode click target, not only the row body Excel-style selection (750c59034) read the modifiers only where a row BODY took the click. Every other target a select-mode surface draws dropped them, so a Shift-click there was a plain toggle: - the track row's checkbox disc passed Qt.NoModifier; - QbzCheckbox and SelectCheck emitted toggled() with no argument, so the Library album rows, the local album rows and the folder rows, which already expected `function (mods)`, received undefined; - AlbumCard emitted selectToggled() with no argument, and the paged local albums grid (299dcde5b) spelled the drop out as Qt.NoModifier. Some targets were never selection targets at all: in select mode the artist and album names on a track row, the artist and label on a Library album row and the artist line of an album card still navigated, leaving the page and the selection mid-gesture. The row links stand down in select mode so the click reaches the row body with its modifiers, and the card's artist line toggles like its artwork and title. A hover-only tooltip on the pulled but cached glyph no longer eats the row's click. Reproduced on the release binary in a nested KWin with injected input: in Local Library Tracks, the checkbox discs of rows 1, Shift 4 and Ctrl 6 selected 3 rows; the same clicks on the row bodies selected 5. No list view, Flickable or wheel code changes; tst_wheel passes unchanged. tst_multi_select clicks each of these targets through the real TrackRow, AlbumListRow, AlbumCard, QbzCheckbox and SelectCheck. cc --- crates/qbz-qt/qml/cards/AlbumCard.qml | 22 +- crates/qbz-qt/qml/controls/QbzCheckbox.qml | 17 +- crates/qbz-qt/qml/rows/TrackRow.qml | 21 +- crates/qbz-qt/qml/views/AlbumListRow.qml | 10 +- .../qml/views/local/LocalAlbumCollection.qml | 9 +- crates/qbz-qt/qml/views/local/SelectCheck.qml | 8 +- .../imports/com/blitzfc/qbz/QbzAlbum.qml | 7 + .../imports/com/blitzfc/qbz/QbzArtist.qml | 6 + .../imports/com/blitzfc/qbz/QbzHome.qml | 6 + .../imports/com/blitzfc/qbz/QbzSearch.qml | 6 + .../qml-tests/imports/com/blitzfc/qbz/qmldir | 4 + scripts/qml-tests/tst_multi_select.qml | 267 ++++++++++++++++++ 12 files changed, 360 insertions(+), 23 deletions(-) create mode 100644 scripts/qml-tests/imports/com/blitzfc/qbz/QbzAlbum.qml create mode 100644 scripts/qml-tests/imports/com/blitzfc/qbz/QbzArtist.qml create mode 100644 scripts/qml-tests/imports/com/blitzfc/qbz/QbzHome.qml create mode 100644 scripts/qml-tests/imports/com/blitzfc/qbz/QbzSearch.qml diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index 4fa255551..e9654351f 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -213,7 +213,10 @@ Rectangle { /// Park the select indicator bottom-right of the art instead of top-right. property bool selectMarkBottom: false property bool selected: false - signal selectToggled() + /// `modifiers` rides straight off the click: a grid host turns Shift into + /// a range (controls/SelectionModel.qml). Every select-mode target on the + /// card — artwork, title, artist line — emits it. + signal selectToggled(int modifiers) // Album blacklist ("Block this album") — LIVE since QbzBlacklist landed // (`blockAlbum(id, title, artist, coverUrl)`). Kept as a property, not @@ -561,7 +564,7 @@ Rectangle { } // .slint:169 — in select mode the card click TOGGLES. if (root.selectMode) { - root.selectToggled() + root.selectToggled(mouse.modifiers) return } if (root.pulledDead) @@ -993,7 +996,7 @@ Rectangle { // .slint:465 — the title carries the SAME target // as the artwork, select mode included. if (root.selectMode) { - root.selectToggled() + root.selectToggled(mouse.modifiers) return } if (root.pulledDead) @@ -1009,7 +1012,8 @@ Rectangle { width: parent.width height: 18 text: root.artist - color: (root.artistId !== "" || root.hostArtistLink) + color: !root.selectMode + && (root.artistId !== "" || root.hostArtistLink) && artistArea.containsMouse ? theme.textPrimary : theme.textMuted font.pixelSize: theme.fontLink - 1 @@ -1019,9 +1023,15 @@ Rectangle { id: artistArea anchors.fill: parent hoverEnabled: true - cursorShape: root.artistId !== "" || root.hostArtistLink + cursorShape: root.selectMode || root.artistId !== "" || root.hostArtistLink ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { + onClicked: function (mouse) { + // In select mode the artist line is the card too: + // opening the artist mid-selection would drop it. + if (root.selectMode) { + root.selectToggled(mouse.modifiers) + return + } if (root.artistId !== "") QbzArtist.openArtist(root.artistId) else if (root.hostArtistLink) diff --git a/crates/qbz-qt/qml/controls/QbzCheckbox.qml b/crates/qbz-qt/qml/controls/QbzCheckbox.qml index 6bc6306ce..411f3cfef 100644 --- a/crates/qbz-qt/qml/controls/QbzCheckbox.qml +++ b/crates/qbz-qt/qml/controls/QbzCheckbox.qml @@ -1,6 +1,11 @@ // QbzCheckbox (primitives/QbzCheckbox.slint) — 18px square, r4, accent fill -// with an accent-text check when on, muted border when off. Emits toggled(); -// like the Slint original it never self-flips (the owner of the state does). +// with an accent-text check when on, muted border when off. Emits +// toggled(modifiers); like the Slint original it never self-flips (the owner of +// the state does). +// +// `modifiers` is the click's (or the key's), straight through: a multi-select +// host turns Shift into a range (controls/SelectionModel.qml). Hosts that only +// flip a flag keep a plain expression handler and never look at it. // // Used by Settings > Local Library (folder selection) and the Plex library // picker, i.e. every place the Slint uses its own QbzCheckbox. @@ -15,7 +20,7 @@ Rectangle { id: root property bool checked: false - signal toggled() + signal toggled(int modifiers) QbzTheme { id: theme } @@ -30,14 +35,14 @@ Rectangle { activeFocusOnTab: root.enabled Accessible.role: Accessible.CheckBox Accessible.checked: root.checked - Accessible.onToggleAction: if (root.enabled) root.toggled() + Accessible.onToggleAction: if (root.enabled) root.toggled(Qt.NoModifier) Keys.onPressed: function (event) { if (root.enabled && !event.isAutoRepeat && (event.key === Qt.Key_Space || event.key === Qt.Key_Return || event.key === Qt.Key_Enter)) { - root.toggled() + root.toggled(event.modifiers) event.accepted = true } } @@ -66,6 +71,6 @@ Rectangle { anchors.fill: parent cursorShape: Qt.PointingHandCursor onPressed: root.forceActiveFocus() - onClicked: root.toggled() + onClicked: function (mouse) { root.toggled(mouse.modifiers) } } } diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 4b699b2d0..aef60795c 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -749,7 +749,10 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.toggleSelect(Qt.NoModifier) + // The disc is where a select-mode click most often lands, + // so it hands over the modifiers exactly like the body: + // a plain `NoModifier` here made Shift-click a toggle. + onClicked: function (mouse) { root.toggleSelect(mouse.modifiers) } } } Text { @@ -969,6 +972,9 @@ Rectangle { MouseArea { anchors.fill: parent hoverEnabled: true + // Hover only: a tooltip must not eat the click the + // row body (play, or a select-mode toggle) owns. + acceptedButtons: Qt.NoButton ToolTip.visible: containsMouse ToolTip.delay: 300 // NEW msgid — the one string this state needs and @@ -1078,7 +1084,12 @@ Rectangle { MouseArea { id: artistLinkArea anchors.fill: parent - enabled: root.artistLink && (artistLine.linkMode || !!root.item.artistId) + // In select mode the whole row is a selection target + // (TrackRow.slint:174): the names stop being links so the + // click — and its Shift / Ctrl — reaches the row body + // instead of opening an artist page mid-selection. + enabled: !root.selectMode + && root.artistLink && (artistLine.linkMode || !!root.item.artistId) hoverEnabled: true cursorShape: (artistLine.linkMode ? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "") @@ -1088,6 +1099,9 @@ Rectangle { artistLine.hotSpan = artistLine.spanAt(mouse.x) } onExited: artistLine.hotSpan = -1 + // Entering select mode under the pointer disables the area + // without an `exited`; the hot name must not stay lit. + onEnabledChanged: if (!enabled) artistLine.hotSpan = -1 // Same routing as the menu's "Go to artist" — Slint drives // both through one handler, so gating only the menu would // leave the LINK navigating to the track's own artist. @@ -1116,7 +1130,8 @@ Rectangle { MouseArea { id: albumArea anchors.fill: parent - enabled: !!root.item.albumId && !root.releaseGone + // Not a link in select mode, for the artist line's reason. + enabled: !root.selectMode && !!root.item.albumId && !root.releaseGone hoverEnabled: true cursorShape: Qt.PointingHandCursor // The names ride along so a release the catalog lost can diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml index a86e72a58..f2772be8a 100644 --- a/crates/qbz-qt/qml/views/AlbumListRow.qml +++ b/crates/qbz-qt/qml/views/AlbumListRow.qml @@ -304,8 +304,11 @@ Rectangle { id: artistArea anchors.fill: parent // Only a real artist id is clickable — otherwise the - // pointer promises a page that cannot open. - enabled: (root.item.artistId || "") !== "" + // pointer promises a page that cannot open. In select + // mode the whole row selects (Shift / Ctrl included), so + // the name stops being a link and the click falls to the + // row body underneath. + enabled: !root.selectMode && (root.item.artistId || "") !== "" hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzArtist.openArtist(root.item.artistId) @@ -331,7 +334,8 @@ Rectangle { MouseArea { id: labelArea anchors.fill: parent - enabled: (root.item.labelId || "") !== "" + // Not a link in select mode, for the artist name's reason. + enabled: !root.selectMode && (root.item.labelId || "") !== "" hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzHome.openLabel(root.item.labelId) diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml index c6248f014..1da27e2de 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml @@ -593,11 +593,14 @@ Item { selected: root.nativeActive ? cardCell.slot.selected === true : root.selected[cardCell.slot.id] === true - onSelectToggled: { + // The card's modifiers, straight through: the + // paged grid (299dcde5b) passed NoModifier here, + // so a Shift-click on a card was a toggle. + onSelectToggled: function (mods) { if (root.nativeActive) - root.nativeToggleSelect(cardCell.slot.nativeIndex, Qt.NoModifier) + root.nativeToggleSelect(cardCell.slot.nativeIndex, mods) else - root.toggleSelect(cardCell.slot.id, Qt.NoModifier) + root.toggleSelect(cardCell.slot.id, mods) } // Non-select mode only — the card routes a // select-mode click to `selectToggled` and never diff --git a/crates/qbz-qt/qml/views/local/SelectCheck.qml b/crates/qbz-qt/qml/views/local/SelectCheck.qml index 0a8c8af4f..3bc06d06e 100644 --- a/crates/qbz-qt/qml/views/local/SelectCheck.qml +++ b/crates/qbz-qt/qml/views/local/SelectCheck.qml @@ -5,6 +5,10 @@ // Slint declares this inline inside TreeRow; the Qt port also needs it on // the album cards and the track rows in multi-select, so it is its own file // (three call sites, one shape). +// +// `toggled` carries the click's modifiers: this disc IS a select-mode click +// target, and one that dropped them turned every Shift-click on it into a +// plain toggle (controls/SelectionModel.qml). import QtQuick import com.blitzfc.qbz @@ -16,7 +20,7 @@ Rectangle { property bool on: false property bool partial: false property int diameter: 13 - signal toggled() + signal toggled(int modifiers) QbzTheme { id: theme } @@ -52,6 +56,6 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.toggled() + onClicked: function (mouse) { root.toggled(mouse.modifiers) } } } diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzAlbum.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzAlbum.qml new file mode 100644 index 000000000..8ae344206 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzAlbum.qml @@ -0,0 +1,7 @@ +pragma Singleton +import QtQuick +QtObject { + property var opened: [] + function openAlbum(id) { opened = opened.concat([String(id)]) } + function openAlbumFrom(id, title, artist) { opened = opened.concat([String(id)]) } +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzArtist.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzArtist.qml new file mode 100644 index 000000000..03e4a4589 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzArtist.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick +QtObject { + property var opened: [] + function openArtist(id) { opened = opened.concat([String(id)]) } +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzHome.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzHome.qml new file mode 100644 index 000000000..00813dc27 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzHome.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick +QtObject { + property var labels: [] + function openLabel(id) { labels = labels.concat([String(id)]) } +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSearch.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSearch.qml new file mode 100644 index 000000000..4cec62812 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSearch.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick +QtObject { + property var submitted: [] + function searchSubmit(text) { submitted = submitted.concat([String(text)]) } +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir index 134e14839..041c163e8 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir @@ -9,3 +9,7 @@ singleton QbzPurchases 1.0 QbzPurchases.qml singleton QbzOrbit 1.0 QbzOrbit.qml singleton QbzAbout 1.0 QbzAbout.qml +singleton QbzArtist 1.0 QbzArtist.qml +singleton QbzAlbum 1.0 QbzAlbum.qml +singleton QbzSearch 1.0 QbzSearch.qml +singleton QbzHome 1.0 QbzHome.qml diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml index 9f4948566..4150de265 100644 --- a/scripts/qml-tests/tst_multi_select.qml +++ b/scripts/qml-tests/tst_multi_select.qml @@ -8,11 +8,278 @@ import QtQuick import QtTest import com.blitzfc.qbz import "../../crates/qbz-qt/qml/controls" +import "../../crates/qbz-qt/qml/rows" +import "../../crates/qbz-qt/qml/cards" +import "../../crates/qbz-qt/qml/views" as Views +import "../../crates/qbz-qt/qml/views/local" as Local Item { id: root width: 1200; height: 1400 + function track(i) { + return { "id": "t" + i, "title": "Track " + i, "artist": "Artist " + i, + "artistId": "a" + i, "album": "Album " + i, "albumId": "al" + i, + "duration": "3:00", "qualityTier": "", "qualityDetail": "" } + } + function album(i) { + return { "id": "b" + i, "title": "Record " + i, "artist": "Band " + i, + "artistId": "band" + i, "label": "Label " + i, "labelId": "l" + i, + "year": "2001", "qualityTier": "", "qualityDetail": "" } + } + function range(n, make) { + var out = [] + for (var i = 0; i < n; i++) + out.push(make(i)) + return out + } + + // ---- the track list (AlbumView / PlaylistView wiring) ------------------- + property var tracks: range(8, track) + property var trackSelected: ({}) + SelectionModel { id: trackSel } + function toggleTrack(id, mods) { + root.trackSelected = trackSel.next(root.trackSelected, id, root.tracks, + mods === undefined ? Qt.NoModifier : mods) + } + + Column { + id: trackColumn + width: 1000 + Repeater { + id: trackRepeater + model: root.tracks + delegate: TrackRow { + required property var modelData + required property int index + width: 1000 + item: modelData + number: index + 1 + artistLink: true + showAlbum: true + selectMode: true + checked: root.trackSelected[modelData.id] === true + onToggleSelect: function (mods) { root.toggleTrack(modelData.id, mods) } + } + } + } + + // ---- the album list (Library albums list wiring) ------------------------ + property var albums: range(6, album) + property var albumSelected: ({}) + SelectionModel { id: albumSel } + function toggleAlbum(id, mods) { + root.albumSelected = albumSel.next(root.albumSelected, id, root.albums, + mods === undefined ? Qt.NoModifier : mods) + } + + Column { + id: albumColumn + y: 420 + width: 1000 + Repeater { + id: albumRepeater + model: root.albums + delegate: Views.AlbumListRow { + required property var modelData + required property int index + width: 1000 + item: modelData + rowIndex: index + selectMode: true + checked: root.albumSelected[modelData.id] === true + onToggleSelect: function (mods) { root.toggleAlbum(modelData.id, mods) } + } + } + } + + // ---- one album card and the two checkbox controls ------------------------ + property var cardMods: [] + AlbumCard { + id: card + x: 1000; y: 0 + albumId: "card" + title: "Card title" + artist: "Card artist" + artistId: "card-artist" + selectMode: true + onSelectToggled: function (mods) { root.cardMods = root.cardMods.concat([mods]) } + } + + property var checkboxMods: [] + QbzCheckbox { + id: checkbox + x: 1000; y: 320 + onToggled: function (mods) { root.checkboxMods = root.checkboxMods.concat([mods]) } + } + property var selectCheckMods: [] + Local.SelectCheck { + id: selectCheck + x: 1040; y: 320 + onToggled: function (mods) { root.selectCheckMods = root.selectCheckMods.concat([mods]) } + } + + TestCase { + name: "MultiSelect" + when: windowShown + + function find(item, pred) { + if (!item) + return null + if (pred(item)) + return item + var kids = item.children || [] + for (var i = 0; i < kids.length; i++) { + var hit = find(kids[i], pred) + if (hit) + return hit + } + return null + } + function shown(item) { + for (var it = item; it; it = it.parent) + if (!it.visible) + return false + return true + } + function textItem(host, text) { + var t = find(host, function (it) { + return it.text === text && it.contentWidth !== undefined && shown(it) + }) + verify(t !== null, "text '" + text + "' is drawn") + return t + } + // Clicks go through the window at the item's position, so whichever + // MouseArea really sits on top there receives them — the point of + // using the real components. + function clickOn(item, x, y, mods) { + var p = item.mapToItem(root, x, y) + mouseClick(root, p.x, p.y, Qt.LeftButton, mods === undefined ? Qt.NoModifier : mods) + wait(20) + } + function selectedIds(map) { + return Object.keys(map).sort().join(",") + } + function trackRow(i) { return trackRepeater.itemAt(i) } + function albumRow(i) { return albumRepeater.itemAt(i) } + function disc(row) { + var d = find(row, function (it) { + return it.width === 14 && it.height === 14 && it.hoverEnabled !== undefined + && it.cursorShape !== undefined && shown(it) + }) + verify(d !== null, "the select-mode disc is drawn") + return d + } + + function init() { + root.trackSelected = ({}) + trackSel.anchorId = "" + root.albumSelected = ({}) + albumSel.anchorId = "" + root.cardMods = [] + root.checkboxMods = [] + root.selectCheckMods = [] + QbzArtist.opened = [] + QbzAlbum.opened = [] + QbzHome.labels = [] + } + + // A point of the row with nothing drawn over it but the body: the + // duration cell. + function bodyPoint(row) { + var duration = textItem(row, "3:00") + return duration.mapToItem(row, duration.width / 2, duration.height / 2) + } + function clickBody(row, mods) { + var p = bodyPoint(row) + clickOn(row, p.x, p.y, mods) + } + + function test_row_body_shift_and_ctrl() { + clickBody(trackRow(1)) + clickBody(trackRow(4), Qt.ShiftModifier) + compare(selectedIds(root.trackSelected), "t1,t2,t3,t4") + clickBody(trackRow(6), Qt.ControlModifier) + compare(selectedIds(root.trackSelected), "t1,t2,t3,t4,t6") + } + + function test_checkbox_disc_shift_and_ctrl() { + clickOn(disc(trackRow(0)), 7, 7) + clickOn(disc(trackRow(3)), 7, 7, Qt.ShiftModifier) + compare(selectedIds(root.trackSelected), "t0,t1,t2,t3", + "Shift on the disc selects the range") + clickOn(disc(trackRow(5)), 7, 7, Qt.ControlModifier) + clickOn(disc(trackRow(7)), 7, 7, Qt.ControlModifier | Qt.ShiftModifier) + compare(selectedIds(root.trackSelected), "t0,t1,t2,t3,t5,t6,t7", + "Ctrl adds a row and Ctrl+Shift adds a second group") + } + + function test_artist_link_selects_in_select_mode() { + clickBody(trackRow(2)) + var name = textItem(trackRow(5), "Artist 5") + clickOn(name, 10, name.height / 2, Qt.ShiftModifier) + compare(QbzArtist.opened.length, 0, "no artist page opens in select mode") + compare(selectedIds(root.trackSelected), "t2,t3,t4,t5") + } + + function test_album_link_selects_in_select_mode() { + var name = textItem(trackRow(6), "Album 6") + clickOn(name, 10, name.height / 2) + compare(QbzAlbum.opened.length, 0, "no album page opens in select mode") + compare(selectedIds(root.trackSelected), "t6") + } + + function test_album_row_checkbox_shift_range() { + var first = find(albumRow(0), function (it) { + return it.checked !== undefined && it.width === 18 && it.height === 18 && shown(it) + }) + var fourth = find(albumRow(3), function (it) { + return it.checked !== undefined && it.width === 18 && it.height === 18 && shown(it) + }) + verify(first !== null && fourth !== null, "the album row checkboxes are drawn") + clickOn(first, 9, 9) + clickOn(fourth, 9, 9, Qt.ShiftModifier) + compare(selectedIds(root.albumSelected), "b0,b1,b2,b3") + } + + function test_album_row_links_select_in_select_mode() { + clickOn(albumRow(1), 600, 32) + var band = textItem(albumRow(4), "Band 4") + clickOn(band, 10, band.height / 2, Qt.ShiftModifier) + compare(QbzArtist.opened.length, 0, "no artist page opens in select mode") + var label = textItem(albumRow(5), "Label 5") + clickOn(label, 10, label.height / 2, Qt.ControlModifier) + compare(QbzHome.labels.length, 0, "no label page opens in select mode") + compare(selectedIds(root.albumSelected), "b1,b2,b3,b4,b5") + } + + function test_album_card_forwards_modifiers() { + clickOn(card, 100, 100, Qt.ShiftModifier) + var title = textItem(card, "Card title") + clickOn(title, 10, title.height / 2, Qt.ControlModifier) + var artist = textItem(card, "Card artist") + clickOn(artist, 10, artist.height / 2, Qt.ShiftModifier) + compare(QbzArtist.opened.length, 0, "no artist page opens in select mode") + compare(root.cardMods.length, 3) + verify((root.cardMods[0] & Qt.ShiftModifier) !== 0, "artwork carries Shift") + verify((root.cardMods[1] & Qt.ControlModifier) !== 0, "title carries Ctrl") + verify((root.cardMods[2] & Qt.ShiftModifier) !== 0, "artist line carries Shift") + } + + function test_checkbox_controls_forward_modifiers() { + clickOn(checkbox, 9, 9, Qt.ShiftModifier) + clickOn(selectCheck, 6, 6, Qt.ControlModifier) + compare(root.checkboxMods.length, 1) + verify((root.checkboxMods[0] & Qt.ShiftModifier) !== 0, "QbzCheckbox carries Shift") + compare(root.selectCheckMods.length, 1) + verify((root.selectCheckMods[0] & Qt.ControlModifier) !== 0, "SelectCheck carries Ctrl") + checkbox.forceActiveFocus() + keyClick(Qt.Key_Space) + compare(root.checkboxMods.length, 2) + compare(root.checkboxMods[1] & Qt.ShiftModifier, 0, "the keyboard toggle is a plain toggle") + } + } + // ---- the selection rule itself --------------------------------------------- SelectionModel { id: rule } SelectionModel { id: keyedRule; idKey: "trackId" } From 0fc19ac7fbf4477d318ff06b9c6f4d2ebfab3fb2 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:30:42 -0600 Subject: [PATCH 03/11] feat(qt): Shift ranges on the artist page's album sections "Play selected" arms a select mode over one album section, and its cards only ever toggled one at a time. Shift now adds the run from the last clicked card in the section's current sort order and Ctrl adds one, through the same SelectionModel every other select-mode list uses. The anchor drops when a section is armed or disarmed. cc --- crates/qbz-qt/qml/views/ArtistView.qml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index 19171aba8..3037c821a 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -350,15 +350,18 @@ Rectangle { property string albumSelectKey: "" property var albumSelected: ({}) readonly property int albumSelectedCount: Object.keys(root.albumSelected).length - function albumSelectToggle(id) { - var m = Object.assign({}, root.albumSelected) - if (m[id]) delete m[id] - else m[id] = true - root.albumSelected = m + /// The armed section's cards follow the same rule as every other + /// select-mode surface: Shift-click ranges over the section's cards in + /// their CURRENT sort order, Ctrl adds one (controls/SelectionModel.qml). + SelectionModel { id: albumSectionSel } + function albumSelectToggle(id, cards, mods) { + root.albumSelected = albumSectionSel.next(root.albumSelected, id, cards || [], + mods === undefined ? Qt.NoModifier : mods) } function albumSelectClear() { root.albumSelectKey = "" root.albumSelected = ({}) + albumSectionSel.anchorId = "" } function albumSectionLabel(key) { return root.albumSelectKey === key @@ -393,6 +396,7 @@ Rectangle { } else if (action === "play-selected") { root.albumSelectKey = key root.albumSelected = ({}) + albumSectionSel.anchorId = "" } else if (action === "cancel-selection") { root.albumSelectClear() } @@ -2261,7 +2265,9 @@ Rectangle { selectMode: root.albumSelectKey === (relSection.section.releaseType || "") selected: root.albumSelected[modelData.id] === true selectMarkBottom: true - onSelectToggled: root.albumSelectToggle(modelData.id) + onSelectToggled: function (mods) { + root.albumSelectToggle(modelData.id, relSection.section.cards || [], mods) + } // ---- smooth append (owner, 2026-08-02) --------------- // "que la aparicion de lo que se cargue, sea smooth" — @@ -3273,7 +3279,9 @@ Rectangle { selectMode: root.albumSelectKey === "library" selected: root.albumSelected[modelData.id] === true selectMarkBottom: true - onSelectToggled: root.albumSelectToggle(modelData.id) + onSelectToggled: function (mods) { + root.albumSelectToggle(modelData.id, root.libAlbumsSorted, mods) + } albumId: modelData.id title: modelData.title artist: modelData.artist From ee3a186f6d2c0dfb413662d0bd38395acecbc63f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:31:13 -0600 Subject: [PATCH 04/11] feat(qt): Shift ranges in My QBZ collections A collection keeps its selection in Rust by persisted position, and detailToggleItemSelect only toggled. It now takes `shift`: a plain or Ctrl click toggles and becomes the anchor, a Shift click adds every row between the anchor and the clicked one in the order the rows are DRAWN (filtered and sorted, not the position order), and the anchor is dropped wherever the selection is cleared. The list rows, their checkbox and the grid cards pass Shift through; the kiosk list passes false. In select mode a row's artwork still started playback and its title and subtitle still opened the item or the artist. They stand down now, so the click selects. cc --- .../qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml | 2 +- .../qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml | 15 ++- .../qml/views/myqbz/MyQbzDetailView.qml | 5 +- crates/qbz-qt/src/myqbz_bridge.rs | 10 +- crates/qbz-qt/src/myqbz_detail_qt.rs | 108 +++++++++++++++--- .../imports/com/blitzfc/qbz/QbzMyQbz.qml | 14 +++ .../qml-tests/imports/com/blitzfc/qbz/qmldir | 1 + scripts/qml-tests/tst_multi_select.qml | 27 +++++ 8 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 scripts/qml-tests/imports/com/blitzfc/qbz/QbzMyQbz.qml diff --git a/crates/qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml b/crates/qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml index 5fa5ddcb2..d9ec9d5cc 100644 --- a/crates/qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml +++ b/crates/qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml @@ -63,7 +63,7 @@ Rectangle { QbzMyQbz.kioskDetailArtwork(first, last, px) } function openRow(r) { - if (root.doc.selectMode) QbzMyQbz.detailToggleItemSelect(r.position) + if (root.doc.selectMode) QbzMyQbz.detailToggleItemSelect(r.position, false) else QbzMyQbz.openItem(r.source, r.itemType, r.sourceItemId) } function entry(label, action) { return {label: root.t(label), action: action} } diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml index 269ffc668..b71f1fc48 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml @@ -171,7 +171,9 @@ Rectangle { } onClicked: function (mouse) { if (mouse.button !== Qt.LeftButton) return - if (root.selectMode) QbzMyQbz.detailToggleItemSelect(root.itemPosition) + if (root.selectMode) + QbzMyQbz.detailToggleItemSelect(root.itemPosition, + (mouse.modifiers & Qt.ShiftModifier) !== 0) else root.openThis() } } @@ -247,7 +249,10 @@ Rectangle { visible: root.selectMode anchors.centerIn: parent on: root.selected - onToggled: QbzMyQbz.detailToggleItemSelect(root.itemPosition) + onToggled: function (mods) { + QbzMyQbz.detailToggleItemSelect(root.itemPosition, + (mods & Qt.ShiftModifier) !== 0) + } } } @@ -295,6 +300,10 @@ Rectangle { MouseArea { id: artArea anchors.fill: parent + // In select mode the whole row selects (Shift / Ctrl + // included): the artwork, title and artist stop playing + // or navigating so the click reaches the row body. + enabled: !root.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzMyQbz.playItem(root.sourceItemId) @@ -321,6 +330,7 @@ Rectangle { MouseArea { id: titleArea anchors.fill: parent + enabled: !root.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.openThis() @@ -343,6 +353,7 @@ Rectangle { MouseArea { id: subArea anchors.fill: parent + enabled: !root.selectMode hoverEnabled: true cursorShape: root.subtitleIsLink ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml index 42be96b04..21d7dd123 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml @@ -1193,7 +1193,10 @@ Rectangle { // selection checkbox rather than bolt one on. selectMode: root.selectMode selected: gcell.cSelected - onSelectToggled: QbzMyQbz.detailToggleItemSelect(gcell.cPosition) + onSelectToggled: function (mods) { + QbzMyQbz.detailToggleItemSelect(gcell.cPosition, + (mods & Qt.ShiftModifier) !== 0) + } // Card body click, non-select mode only (the card // routes a select-mode click to `selectToggled`). onOpenRequested: QbzMyQbz.openItem(gcell.cSource, diff --git a/crates/qbz-qt/src/myqbz_bridge.rs b/crates/qbz-qt/src/myqbz_bridge.rs index 188fdc52f..f0be2cae9 100644 --- a/crates/qbz-qt/src/myqbz_bridge.rs +++ b/crates/qbz-qt/src/myqbz_bridge.rs @@ -162,9 +162,11 @@ pub mod qbz_myqbz_bridge { /// Leaving select mode clears the selection. #[qinvokable] fn detail_toggle_select_mode(self: Pin<&mut QbzMyQbz>); - /// `position` is the 0-based persisted position (the stable key). + /// `position` is the 0-based persisted position (the stable key); + /// `shift` = the click held Shift, i.e. add the range from the last + /// plain click (myqbz_detail_qt::toggle_item_select). #[qinvokable] - fn detail_toggle_item_select(self: Pin<&mut QbzMyQbz>, position: i32); + fn detail_toggle_item_select(self: Pin<&mut QbzMyQbz>, position: i32, shift: bool); /// Resets type/source/sort; the search text is left INTACT /// (myqbz_detail.rs:540-541). #[qinvokable] @@ -472,8 +474,8 @@ impl qbz_myqbz_bridge::QbzMyQbz { crate::myqbz_detail_qt::toggle_select_mode(); } - pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32) { - crate::myqbz_detail_qt::toggle_item_select(position); + pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32, shift: bool) { + crate::myqbz_detail_qt::toggle_item_select(position, shift); } pub fn detail_reset_filters(self: Pin<&mut Self>) { diff --git a/crates/qbz-qt/src/myqbz_detail_qt.rs b/crates/qbz-qt/src/myqbz_detail_qt.rs index fef26fc17..47ce475ec 100644 --- a/crates/qbz-qt/src/myqbz_detail_qt.rs +++ b/crates/qbz-qt/src/myqbz_detail_qt.rs @@ -463,6 +463,43 @@ static RESOLVE_CACHE: LazyLock>> = /// the bulk actions in agreement with what the user sees (spec 02 §12 #13). static SELECTED: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); +/// The POSITION a Shift-click ranges from (the last plain or Ctrl click). +/// Dropped wherever `SELECTED` is cleared, so a range never measures from a +/// row of a previous derive. Lock order: DOC, then SELECTED, then this. +static SELECT_ANCHOR: Mutex> = Mutex::new(None); + +fn clear_select_anchor() { + *SELECT_ANCHOR.lock().unwrap() = None; +} + +/// One select-mode click over the rows as DRAWN (`order` = the rendered +/// positions, top to bottom). The rule every select-mode surface shares +/// (qml/controls/SelectionModel.qml): a plain or Ctrl click toggles `position` +/// and becomes the anchor; Shift ADDS every row from the anchor to `position` +/// and leaves the anchor where it is, so the range can be adjusted. Without a +/// drawn anchor a Shift-click is a plain toggle. +fn apply_item_click( + order: &[i32], + selected: &mut HashSet, + anchor: &mut Option, + position: i32, + shift: bool, +) { + if shift { + let from = anchor.and_then(|a| order.iter().position(|p| *p == a)); + let to = order.iter().position(|p| *p == position); + if let (Some(from), Some(to)) = (from, to) { + let (lo, hi) = if from <= to { (from, to) } else { (to, from) }; + selected.extend(order[lo..=hi].iter().copied()); + return; + } + } + if !selected.remove(&position) { + selected.insert(position); + } + *anchor = Some(position); +} + /// Fingerprint of the last derive's filter/sort/search inputs — what decides /// whether a rebuild is a RE-DERIVE (clears the selection) or a row patch. static LAST_FILTER_KEY: Mutex> = Mutex::new(None); @@ -1080,6 +1117,7 @@ fn rebuild(d: &mut DetailDoc) { // reference, the COUNT below is recomputed from the same set, so the // bulk bar can never claim a selection the rows do not show. SELECTED.lock().unwrap().clear(); + clear_select_anchor(); } let view = filtered_sorted(d); @@ -1431,6 +1469,7 @@ fn reset() { // `apply` seeds this one back from it. OPEN_ROWS.lock().unwrap().clear(); SELECTED.lock().unwrap().clear(); + clear_select_anchor(); *LAST_FILTER_KEY.lock().unwrap() = None; // Close the persist gate until `apply` restores this collection's prefs. PREFS_HYDRATED.store(false, Ordering::SeqCst); @@ -2158,6 +2197,7 @@ pub(crate) fn toggle_select_mode() { let on = !d.select_mode; if !on { SELECTED.lock().unwrap().clear(); + clear_select_anchor(); for row in d.items.iter_mut() { row.selected = false; } @@ -2169,24 +2209,19 @@ pub(crate) fn toggle_select_mode() { publish(&doc); } -/// Toggle one row's selection by its stable POSITION, then recount. A row patch -/// — never a re-derive. -pub(crate) fn toggle_item_select(position: i32) { +/// One select-mode click on a row, by its stable POSITION: a toggle, or with +/// `shift` the range from the anchor (`apply_item_click`). Then every drawn +/// row's tick and the count follow the set. A row patch — never a re-derive. +pub(crate) fn toggle_item_select(position: i32, shift: bool) { let doc = with_doc(|d| { - let now_selected = { - let mut set = SELECTED.lock().unwrap(); - if set.contains(&position) { - set.remove(&position); - false - } else { - set.insert(position); - true - } - }; - if let Some(row) = d.items.iter_mut().find(|r| r.position == position) { - row.selected = now_selected; + let order: Vec = d.items.iter().map(|r| r.position).collect(); + let mut set = SELECTED.lock().unwrap(); + let mut anchor = SELECT_ANCHOR.lock().unwrap(); + apply_item_click(&order, &mut set, &mut anchor, position, shift); + for row in d.items.iter_mut() { + row.selected = set.contains(&row.position); } - d.selected_count = SELECTED.lock().unwrap().len() as i32; + d.selected_count = set.len() as i32; d.clone() }); publish(&doc); @@ -2197,6 +2232,7 @@ pub(crate) fn toggle_item_select(position: i32) { pub(crate) fn clear_selection() { let doc = with_doc(|d| { SELECTED.lock().unwrap().clear(); + clear_select_anchor(); for row in d.items.iter_mut() { row.selected = false; } @@ -2374,6 +2410,7 @@ pub(crate) fn teardown() { RESOLVE_CACHE.lock().unwrap().clear(); OPEN_ROWS.lock().unwrap().clear(); SELECTED.lock().unwrap().clear(); + clear_select_anchor(); *LAST_FILTER_KEY.lock().unwrap() = None; PREFS_HYDRATED.store(false, Ordering::SeqCst); let doc = with_doc(|d| { @@ -2495,6 +2532,45 @@ pub(crate) fn bulk_action(id: String) { mod tests { use super::*; + fn picked(set: &HashSet) -> Vec { + let mut out: Vec = set.iter().copied().collect(); + out.sort_unstable(); + out + } + + #[test] + fn select_click_ranges_over_the_drawn_order() { + // Rows drawn in a sorted order that is NOT the position order. + let order = [4, 0, 7, 2, 9, 5]; + let mut set = HashSet::new(); + let mut anchor = None; + apply_item_click(&order, &mut set, &mut anchor, 0, false); + assert_eq!(anchor, Some(0)); + apply_item_click(&order, &mut set, &mut anchor, 9, true); + // 0, 7, 2, 9 are the drawn rows between them; 4 and 5 are outside. + assert_eq!(picked(&set), vec![0, 2, 7, 9]); + assert_eq!(anchor, Some(0), "a Shift-click keeps the anchor"); + // Re-dragging the range upward only ever adds. + apply_item_click(&order, &mut set, &mut anchor, 4, true); + assert_eq!(picked(&set), vec![0, 2, 4, 7, 9]); + } + + #[test] + fn select_click_toggles_and_moves_the_anchor() { + let order = [1, 2, 3, 4]; + let mut set = HashSet::new(); + let mut anchor = None; + apply_item_click(&order, &mut set, &mut anchor, 3, false); + apply_item_click(&order, &mut set, &mut anchor, 3, false); + assert!(set.is_empty(), "a second plain click unticks the row"); + assert_eq!(anchor, Some(3)); + // Shift without a drawn anchor is a plain toggle that anchors there. + let mut gone = Some(42); + apply_item_click(&order, &mut set, &mut gone, 2, true); + assert_eq!(picked(&set), vec![2]); + assert_eq!(gone, Some(2)); + } + fn item( position: i32, title: &str, diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzMyQbz.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzMyQbz.qml new file mode 100644 index 000000000..8a14266b1 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzMyQbz.qml @@ -0,0 +1,14 @@ +pragma Singleton +import QtQuick +QtObject { + property var selectCalls: [] + property var opened: [] + property var played: [] + function detailToggleItemSelect(position, shift) { + selectCalls = selectCalls.concat([{ "position": position, "shift": shift }]) + } + function openItem(source, itemType, id) { opened = opened.concat([String(id)]) } + function openArtist(source, name, id) { opened = opened.concat(["artist:" + name]) } + function playItem(id) { played = played.concat([String(id)]) } + function detailToggleRowExpand(id) {} +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir index 041c163e8..3107effa1 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir @@ -13,3 +13,4 @@ singleton QbzArtist 1.0 QbzArtist.qml singleton QbzAlbum 1.0 QbzAlbum.qml singleton QbzSearch 1.0 QbzSearch.qml singleton QbzHome 1.0 QbzHome.qml +singleton QbzMyQbz 1.0 QbzMyQbz.qml diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml index 4150de265..91d4a6027 100644 --- a/scripts/qml-tests/tst_multi_select.qml +++ b/scripts/qml-tests/tst_multi_select.qml @@ -12,6 +12,7 @@ import "../../crates/qbz-qt/qml/rows" import "../../crates/qbz-qt/qml/cards" import "../../crates/qbz-qt/qml/views" as Views import "../../crates/qbz-qt/qml/views/local" as Local +import "../../crates/qbz-qt/qml/views/myqbz" as MyQbz Item { id: root @@ -119,6 +120,17 @@ Item { onToggled: function (mods) { root.selectCheckMods = root.selectCheckMods.concat([mods]) } } + // ---- a My QBZ collection row (selection lives in Rust) ---------------------- + MyQbz.MyQbzDetailRow { + id: myqbzRow + x: 0; y: 820 + width: 1000 + selectMode: true + item: ({ "position": 3, "itemType": "album", "source": "qobuz", + "sourceItemId": "alb3", "title": "Collected record", "subtitle": "Collected band", + "subtitleIsLink": true, "artistId": "cb" }) + } + TestCase { name: "MultiSelect" when: windowShown @@ -182,6 +194,9 @@ Item { QbzArtist.opened = [] QbzAlbum.opened = [] QbzHome.labels = [] + QbzMyQbz.selectCalls = [] + QbzMyQbz.opened = [] + QbzMyQbz.played = [] } // A point of the row with nothing drawn over it but the body: the @@ -278,6 +293,18 @@ Item { compare(root.checkboxMods.length, 2) compare(root.checkboxMods[1] & Qt.ShiftModifier, 0, "the keyboard toggle is a plain toggle") } + + function test_myqbz_row_targets_select_with_shift() { + var title = textItem(myqbzRow, "Collected record") + clickOn(title, 10, title.height / 2) + var subtitle = textItem(myqbzRow, "Collected band") + clickOn(subtitle, 10, subtitle.height / 2, Qt.ShiftModifier) + compare(QbzMyQbz.opened.length, 0, "nothing opens in select mode") + compare(QbzMyQbz.selectCalls.length, 2) + compare(QbzMyQbz.selectCalls[0].position, 3) + compare(QbzMyQbz.selectCalls[0].shift, false) + compare(QbzMyQbz.selectCalls[1].shift, true, "Shift reaches the Rust range") + } } // ---- the selection rule itself --------------------------------------------- From b43771cc3e746b9a951ee80afb6eac35f2b74bd4 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:31:22 -0600 Subject: [PATCH 05/11] feat(qt): Shift ranges in the Local Library folder rail The rail's selection lives in Rust as a set of file paths, so it had no anchor and stayed toggle-only (750c59034 said as much). The rail now keeps the anchor path and, on Shift, sends every drawn row from it to the clicked row to a new treeSelectRange, which SELECTS: a folder with its whole subtree, a track with its own record, so a range only ever adds. In select mode a Shift or Ctrl click on a row body selects it instead of opening the folder; a plain click still opens it. The rail's selection mutations used to be spawned side by side. With ranges that became a race: a range that finished before the click that set its anchor would be undone by that late toggle. They now run one at a time in click order, with select-all, clear and leaving select mode in the same queue. cc --- .../qbz-qt/qml/views/local/LocalTreeRail.qml | 59 +++++-- crates/qbz-qt/qml/views/local/TreeRow.qml | 9 + crates/qbz-qt/src/local_bridge.rs | 9 + crates/qbz-qt/src/local_bulk.rs | 165 +++++++++++++++--- crates/qbz-qt/src/local_tree.rs | 49 ++++-- .../imports/com/blitzfc/qbz/QbzLocal.qml | 14 ++ scripts/qml-tests/tst_multi_select.qml | 71 ++++++++ 7 files changed, 333 insertions(+), 43 deletions(-) diff --git a/crates/qbz-qt/qml/views/local/LocalTreeRail.qml b/crates/qbz-qt/qml/views/local/LocalTreeRail.qml index 4e9b32957..621a1d029 100644 --- a/crates/qbz-qt/qml/views/local/LocalTreeRail.qml +++ b/crates/qbz-qt/qml/views/local/LocalTreeRail.qml @@ -20,6 +20,52 @@ Item { QbzTheme { id: theme } + // ---- Shift / Ctrl on the rail's checkboxes ---- + // The selection lives in RUST (QbzLocal.tree*Select owns the set of + // paths); what the rail holds is the one piece the rule needs on top of + // it, the ANCHOR: the path of the last plain or Ctrl click. A Shift-click + // looks it up among the rows drawn right now and SELECTS every row from + // there to the clicked one (QbzLocal.treeSelectRange — additive, like + // controls/SelectionModel.qml); an anchor that is not drawn any more + // (collapsed, filtered away) makes it a plain toggle again. + property string selectAnchor: "" + Connections { + target: root.view + function onTreeSelectModeChanged() { + if (!root.view.treeSelectMode) + root.selectAnchor = "" + } + } + /// The rows from `anchorPath` to `path`, inclusive, in rail order, as the + /// `{path, isFolder}` pairs treeSelectRange takes. [] when either is not + /// drawn. + function rangeNodes(rows, anchorPath, path) { + var from = -1 + var to = -1 + for (var i = 0; i < rows.length; i++) { + if (rows[i].path === anchorPath) from = i + if (rows[i].path === path) to = i + } + if (from < 0 || to < 0) + return [] + var out = [] + for (var j = Math.min(from, to); j <= Math.max(from, to); j++) + out.push({ "path": rows[j].path, "isFolder": rows[j].isFolder === true }) + return out + } + function toggleNodeSelect(node, mods) { + if (((mods || 0) & Qt.ShiftModifier) !== 0 && root.selectAnchor !== "") { + var nodes = root.rangeNodes(root.view.tree || [], root.selectAnchor, node.path) + if (nodes.length > 0) { + QbzLocal.treeSelectRange(JSON.stringify(nodes)) + return + } + } + if (node.isFolder) QbzLocal.treeToggleFolderSelect(node.path) + else QbzLocal.treeToggleTrackSelect(node.path) + root.selectAnchor = node.path + } + Column { anchors.fill: parent anchors.leftMargin: 12 @@ -149,16 +195,9 @@ 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) - } + // Shift ranges from the rail's anchor (see + // `toggleNodeSelect` at the top of this file). + onToggleSelect: function (mods) { root.toggleNodeSelect(modelData, mods) } } } QbzScrollBar { diff --git a/crates/qbz-qt/qml/views/local/TreeRow.qml b/crates/qbz-qt/qml/views/local/TreeRow.qml index 18e94645d..e0c7dea77 100644 --- a/crates/qbz-qt/qml/views/local/TreeRow.qml +++ b/crates/qbz-qt/qml/views/local/TreeRow.qml @@ -50,6 +50,15 @@ Rectangle { if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y) return } + // In select mode a Shift or Ctrl click on the body is a selection + // gesture, as in any file list: it ticks (or ranges) the row + // instead of opening the folder. A plain click still opens it. + if (root.selectMode + && (mouse.modifiers & (Qt.ShiftModifier | Qt.ControlModifier + | Qt.MetaModifier)) !== 0) { + root.toggleSelect(mouse.modifiers) + return + } if (root.node.isFolder) root.activated() } } diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 602f94c72..090073fbf 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -365,6 +365,11 @@ pub mod qbz_local { /// Track checkbox: toggle one row by file path. #[qinvokable] fn tree_toggle_track_select(self: Pin<&mut QbzLocal>, path: QString); + /// Shift-click on a rail checkbox: SELECT every visible row from the + /// anchor to the clicked one. `nodes_json` = + /// `[{"path": …, "isFolder": …}]` in rail order. + #[qinvokable] + fn tree_select_range(self: Pin<&mut QbzLocal>, nodes_json: QString); /// Tree-rail bulk bar. #[qinvokable] fn folders_bulk_action(self: Pin<&mut QbzLocal>, action: QString); @@ -1757,6 +1762,10 @@ impl qbz_local::QbzLocal { crate::local_bulk::toggle_track_select(path.to_string()); } + pub fn tree_select_range(self: Pin<&mut Self>, nodes_json: QString) { + crate::local_bulk::select_tree_range(nodes_json.to_string()); + } + pub fn folders_bulk_action(self: Pin<&mut Self>, action: QString) { crate::local_bulk::folders_bulk_action(action.to_string()); } diff --git a/crates/qbz-qt/src/local_bulk.rs b/crates/qbz-qt/src/local_bulk.rs index b76f09c32..9ab5ed209 100644 --- a/crates/qbz-qt/src/local_bulk.rs +++ b/crates/qbz-qt/src/local_bulk.rs @@ -24,7 +24,9 @@ //! This file is the Qt-facing half of the tree selection; the state and the //! blocking mutators live in `local_tree.rs`, next to the tree they annotate. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use cxx_qt_lib::QString; use qbz_library::LocalTrack; @@ -61,29 +63,124 @@ fn publish_selection() { // --------------------------------------------------------------------------- /// Rail header toggle. Leaving select mode drops the selection, so the -/// republish is what hollows every checkbox again. +/// republish is what hollows every checkbox again. The drop waits behind any +/// checkbox click still queued, or that click would tick a row again after it. pub fn set_select_mode(on: bool) { - tree::set_tree_select_mode(on); - publish_selection(); + if on { + publish_selection(); + } else { + run_tree_select(TreeSelectOp::LeaveSelectMode); + } +} + +/// One tree-selection mutation, as the user issued it. +enum TreeSelectOp { + Folder(String), + Track(String), + Range(Vec<(String, bool)>), + SelectAll, + Clear, + LeaveSelectMode, +} + +/// The rail's selection mutations run ONE AT A TIME, in the order they were +/// clicked. Each is a blocking DB read, and they used to be spawned side by +/// side: harmless while every click was a toggle of its own row, but a +/// Shift-range that finished before the plain click that set its anchor would +/// see that folder already selected by the range — and the late toggle would +/// then UNselect it. Clicks push here on the Qt thread, so the queue order is +/// the click order; a single drainer works through it. +static TREE_SELECT_OPS: Mutex> = Mutex::new(VecDeque::new()); +static TREE_SELECT_DRAINING: AtomicBool = AtomicBool::new(false); + +fn run_tree_select(op: TreeSelectOp) { + TREE_SELECT_OPS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push_back(op); + if TREE_SELECT_DRAINING.swap(true, Ordering::SeqCst) { + return; + } + crate::spawn(async move { + // A drainer that dies mid-queue must not leave the flag up, or no + // later click would ever be applied. + struct Unwedge; + impl Drop for Unwedge { + fn drop(&mut self) { + if std::thread::panicking() { + TREE_SELECT_DRAINING.store(false, Ordering::SeqCst); + } + } + } + let _unwedge = Unwedge; + loop { + let next = TREE_SELECT_OPS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .pop_front(); + let Some(op) = next else { + TREE_SELECT_DRAINING.store(false, Ordering::SeqCst); + // A click that queued between the empty pop and the store saw + // a drainer still running and spawned none: take it over. + let pending = !TREE_SELECT_OPS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_empty(); + if pending && !TREE_SELECT_DRAINING.swap(true, Ordering::SeqCst) { + continue; + } + break; + }; + let _ = tokio::task::spawn_blocking(move || match op { + TreeSelectOp::Folder(path) => tree::toggle_folder_select_blocking(&path), + TreeSelectOp::Track(path) => tree::toggle_track_select_blocking(&path), + TreeSelectOp::Range(nodes) => tree::select_nodes_blocking(&nodes), + TreeSelectOp::SelectAll => tree::tree_select_all_blocking(), + TreeSelectOp::Clear => tree::tree_clear_selection(), + TreeSelectOp::LeaveSelectMode => tree::set_tree_select_mode(false), + }) + .await; + publish_selection(); + } + }); } /// Folder checkbox: recursive, so it is a DB read on a blocking thread. pub fn toggle_folder_select(path: String) { - crate::spawn(async move { - let _ = - tokio::task::spawn_blocking(move || tree::toggle_folder_select_blocking(&path)).await; - publish_selection(); - }); + run_tree_select(TreeSelectOp::Folder(path)); } /// Track checkbox: a deselect is pure state, a select resolves the record /// from the parent folder listing — blocking either way. pub fn toggle_track_select(path: String) { - crate::spawn(async move { - let _ = - tokio::task::spawn_blocking(move || tree::toggle_track_select_blocking(&path)).await; - publish_selection(); - }); + run_tree_select(TreeSelectOp::Track(path)); +} + +/// Shift-click on a rail checkbox: `nodes_json` is the visible rows from the +/// anchor to the clicked one, `[{"path": …, "isFolder": …}]`, all SELECTED +/// (`local_tree::select_nodes_blocking`). +pub fn select_tree_range(nodes_json: String) { + let nodes = parse_tree_range(&nodes_json); + if nodes.is_empty() { + return; + } + run_tree_select(TreeSelectOp::Range(nodes)); +} + +fn parse_tree_range(json: &str) -> Vec<(String, bool)> { + #[derive(serde::Deserialize)] + struct Node { + #[serde(default)] + path: String, + #[serde(rename = "isFolder", default)] + is_folder: bool, + } + serde_json::from_str::>(json) + .unwrap_or_default() + .into_iter() + .filter(|node| !node.path.is_empty()) + .map(|node| (node.path, node.is_folder)) + .collect() } // --------------------------------------------------------------------------- @@ -99,16 +196,11 @@ pub fn toggle_track_select(path: String) { pub fn folders_bulk_action(action: String) { match action.as_str() { "select-all" => { - // Two-way: "check all" un-checks when everything already is. - crate::spawn(async move { - let _ = tokio::task::spawn_blocking(tree::tree_select_all_blocking).await; - publish_selection(); - }); - } - "clear" => { - tree::tree_clear_selection(); - publish_selection(); + // Two-way: "check all" un-checks when everything already is. In + // the same queue as the checkboxes, so it sees them all applied. + run_tree_select(TreeSelectOp::SelectAll); } + "clear" => run_tree_select(TreeSelectOp::Clear), _ => { let rows = tree::tree_selected_snapshot(); crate::spawn(async move { @@ -336,3 +428,30 @@ async fn enqueue_rows(rows: Vec, mode: &str) { } crate::playback_qt::publish_queue(&runtime).await; } + +#[cfg(test)] +mod tree_range_tests { + use super::parse_tree_range; + + #[test] + fn range_nodes_keep_their_order_and_kind() { + let nodes = parse_tree_range( + r#"[{"path":"/m/a","isFolder":true},{"path":"/m/a/1.flac","isFolder":false},{"path":"/m/b","isFolder":true}]"#, + ); + assert_eq!( + nodes, + vec![ + ("/m/a".to_string(), true), + ("/m/a/1.flac".to_string(), false), + ("/m/b".to_string(), true), + ] + ); + } + + #[test] + fn range_nodes_without_a_path_or_bad_json_are_dropped() { + assert!(parse_tree_range("not json").is_empty()); + let nodes = parse_tree_range(r#"[{"isFolder":true},{"path":"/m/c"}]"#); + assert_eq!(nodes, vec![("/m/c".to_string(), false)]); + } +} diff --git a/crates/qbz-qt/src/local_tree.rs b/crates/qbz-qt/src/local_tree.rs index 94aef190f..f824d9737 100644 --- a/crates/qbz-qt/src/local_tree.rs +++ b/crates/qbz-qt/src/local_tree.rs @@ -364,25 +364,54 @@ pub fn toggle_track_select_blocking(path: &str) { if sel(|s| s.remove(path)).is_some() { return; } - // SACD leaves deliberately carry their authoritative virtual - // `sacd:#N` path, whose filesystem parent is not the synthetic ISO - // folder shown in the tree. Resolve the exact key first; ordinary files - // take this same cheap indexed path and no longer need a parent listing. - if let Some(track) = with_db(|db| db.get_track_by_path(path)).flatten() { + if let Some(track) = resolve_track_record(path) { sel(|s| { s.insert(path.to_string(), track); }); - return; + } +} + +/// The library record behind a track leaf's path. +/// +/// SACD leaves deliberately carry their authoritative virtual +/// `sacd:#N` path, whose filesystem parent is not the synthetic ISO +/// folder shown in the tree. Resolve the exact key first; ordinary files take +/// this same cheap indexed path and no longer need a parent listing. +fn resolve_track_record(path: &str) -> Option { + if let Some(track) = with_db(|db| db.get_track_by_path(path)).flatten() { + return Some(track); } let parent = std::path::Path::new(path) .parent() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); let tracks = with_db(|db| db.list_folder_tracks(&parent, false)).unwrap_or_default(); - if let Some(t) = tracks.into_iter().find(|t| t.file_path == path) { - sel(|s| { - s.insert(path.to_string(), t); - }); + tracks.into_iter().find(|t| t.file_path == path) +} + +/// Shift-click in the rail: SELECT — never toggle — every node the range +/// covers (`nodes` = `(path, is_folder)` of the visible rows from the anchor +/// to the clicked row). A folder brings its whole subtree and a track its own +/// record. Inserting is idempotent, so a folder and its expanded children +/// sharing the range, or rows that were already ticked, simply stay ticked: +/// a range only ever adds, like every other select-mode surface. +pub fn select_nodes_blocking(nodes: &[(String, bool)]) { + for (path, is_folder) in nodes { + if *is_folder { + let tracks = + with_db(|db| db.list_folder_tracks_recursive(path, false)).unwrap_or_default(); + sel(|s| { + for t in tracks { + s.insert(t.file_path.clone(), t); + } + }); + } else if !sel(|s| s.contains_key(path.as_str())) { + if let Some(track) = resolve_track_record(path) { + sel(|s| { + s.insert(path.clone(), track); + }); + } + } } } diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml index 194020216..9fd60f90d 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml @@ -6,4 +6,18 @@ QtObject { property int pairingCancels: 0 function mediaCancelQuickConnect() { pairingCancels++ } function mediaQuickConnect(url) {} + + // Folder tree rail (LocalTreeRail / TreeRow). + property bool localTreeLoading: false + property int localTreeSelectedCount: 0 + property var treeCalls: [] + function treeToggleFolderSelect(path) { treeCalls = treeCalls.concat(["folder:" + path]) } + function treeToggleTrackSelect(path) { treeCalls = treeCalls.concat(["track:" + path]) } + function treeSelectRange(json) { treeCalls = treeCalls.concat(["range:" + json]) } + function treeToggle(path, open) {} + function treeCollapseAll() {} + function treeSearch(query) {} + function foldersBulkAction(action) {} + function playFolder(path) {} + function enqueue(kind, id, mode) {} } diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml index 91d4a6027..d63370c59 100644 --- a/scripts/qml-tests/tst_multi_select.qml +++ b/scripts/qml-tests/tst_multi_select.qml @@ -131,6 +131,31 @@ Item { "subtitleIsLink": true, "artistId": "cb" }) } + // ---- the Local Library folder rail (selection lives in Rust) ---------------- + QtObject { + id: railView + property bool treeSelectMode: true + property string selectedFolder: "" + property string treeSearch: "" + property bool skelPhase: false + property var opened: [] + property var tree: [ + { "path": "/m/a", "segment": "a", "depth": 0, "isFolder": true, "canExpand": true }, + { "path": "/m/a/1.flac", "segment": "1.flac", "depth": 1, "isFolder": false }, + { "path": "/m/b", "segment": "b", "depth": 0, "isFolder": true, "canExpand": true }, + { "path": "/m/c", "segment": "c", "depth": 0, "isFolder": true, "canExpand": true }, + { "path": "/m/d", "segment": "d", "depth": 0, "isFolder": true, "canExpand": true } + ] + function toggleTreeSelectMode() { treeSelectMode = !treeSelectMode } + function selectFolder(path) { opened = opened.concat([path]) } + } + Local.LocalTreeRail { + id: rail + x: 1000; y: 400 + width: 200; height: 400 + view: railView + } + TestCase { name: "MultiSelect" when: windowShown @@ -197,6 +222,9 @@ Item { QbzMyQbz.selectCalls = [] QbzMyQbz.opened = [] QbzMyQbz.played = [] + QbzLocal.treeCalls = [] + railView.opened = [] + railView.treeSelectMode = true } // A point of the row with nothing drawn over it but the body: the @@ -305,6 +333,49 @@ Item { compare(QbzMyQbz.selectCalls[0].shift, false) compare(QbzMyQbz.selectCalls[1].shift, true, "Shift reaches the Rust range") } + + function railRow(path) { + var list = find(rail, function (it) { return it.model !== undefined && it.itemAtIndex !== undefined }) + verify(list !== null, "the rail list exists") + for (var i = 0; i < railView.tree.length; i++) + if (railView.tree[i].path === path) + return list.itemAtIndex(i) + return null + } + function railCheck(path) { + var row = railRow(path) + verify(row !== null, "rail row " + path + " is drawn") + var check = find(row, function (it) { + return it.partial !== undefined && it.on !== undefined && shown(it) + }) + verify(check !== null, "rail checkbox " + path + " is drawn") + return check + } + + function test_folder_rail_shift_range_and_modifier_body_click() { + tryVerify(function () { return railRow("/m/d") !== null }, 2000) + clickOn(railCheck("/m/a"), 6, 6) + clickOn(railCheck("/m/c"), 6, 6, Qt.ShiftModifier) + compare(QbzLocal.treeCalls.length, 2) + compare(QbzLocal.treeCalls[0], "folder:/m/a") + var range = JSON.parse(QbzLocal.treeCalls[1].slice("range:".length)) + compare(range.map(function (n) { return n.path }).join(","), "/m/a,/m/a/1.flac,/m/b,/m/c") + compare(range[1].isFolder, false) + // A Ctrl click on a row body selects instead of opening the folder. + var d = railRow("/m/d") + clickOn(d, d.width - 20, d.height / 2, Qt.ControlModifier) + compare(QbzLocal.treeCalls[2], "folder:/m/d") + compare(railView.opened.length, 0) + // A plain click on the body still opens it. + clickOn(d, d.width - 20, d.height / 2) + compare(railView.opened.length, 1) + // Leaving select mode forgets the anchor: Shift is a plain toggle. + railView.treeSelectMode = false + railView.treeSelectMode = true + wait(0) + clickOn(railCheck("/m/b"), 6, 6, Qt.ShiftModifier) + compare(QbzLocal.treeCalls[3], "folder:/m/b") + } } // ---- the selection rule itself --------------------------------------------- From b1791d6223d79239b63d5a80d16cf710c1c74ccf Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:31:32 -0600 Subject: [PATCH 06/11] feat(qt): Shift ranges in the offline manager The manager's checkboxes toggled one track at a time. They now follow the shared rule over the track rows as drawn, album headers excluded, and a Shift or Ctrl click on a track row selects instead of starting playback; a plain click still plays. The Slint had this surface on its range table (SURFACE_OFFLINE). cc --- .../qbz-qt/qml/views/OfflineManagerView.qml | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/qbz-qt/qml/views/OfflineManagerView.qml b/crates/qbz-qt/qml/views/OfflineManagerView.qml index adb303195..1c71de716 100644 --- a/crates/qbz-qt/qml/views/OfflineManagerView.qml +++ b/crates/qbz-qt/qml/views/OfflineManagerView.qml @@ -107,18 +107,22 @@ Rectangle { var p = root.livePatch[row.trackId] return (row.kind === "track" && p !== undefined) ? p.status : row.status } - function toggleSelect(trackId) { - var s = Object.assign({}, root.selected) - if (s[trackId] === true) - delete s[trackId] - else - s[trackId] = true - root.selected = s + /// The rule every multi-select list shares (controls/SelectionModel.qml): + /// a plain or Ctrl click toggles one track, Shift adds the range from the + /// last one — over the TRACK rows as drawn, never the album headers + /// between them. The Slint had this surface on its Shift-range table + /// (selection.rs SURFACE_OFFLINE); the port left it toggle-only. + SelectionModel { id: offlineSel; idKey: "trackId" } + function toggleSelect(trackId, mods) { + var tracks = root.rows.filter(function (row) { return row.kind === "track" }) + root.selected = offlineSel.next(root.selected, trackId, tracks, + mods === undefined ? Qt.NoModifier : mods) } function bulkAction(id) { if (id === "clear") { root.selected = ({}) + offlineSel.anchorId = "" return } if (id === "select-all") { @@ -507,8 +511,19 @@ Rectangle { hoverEnabled: true cursorShape: rowItem.isAlbum ? Qt.ArrowCursor : Qt.PointingHandCursor - onClicked: if (!rowItem.isAlbum) - QbzOffline.playTrack(rowItem.modelData.trackId) + // A plain click plays; a Shift or Ctrl click is + // a selection gesture, as in any file list, so + // it ticks (or ranges) instead of starting + // playback. + onClicked: function (mouse) { + if (rowItem.isAlbum) + return + if ((mouse.modifiers & (Qt.ShiftModifier | Qt.ControlModifier + | Qt.MetaModifier)) !== 0) + root.toggleSelect(rowItem.modelData.trackId, mouse.modifiers) + else + QbzOffline.playTrack(rowItem.modelData.trackId) + } } Row { @@ -526,7 +541,9 @@ Rectangle { anchors.centerIn: parent visible: !rowItem.isAlbum checked: rowItem.checked - onToggled: root.toggleSelect(rowItem.modelData.trackId) + onToggled: function (mods) { + root.toggleSelect(rowItem.modelData.trackId, mods) + } } } From cf3069386bd19490a9a58617c6d4c60c3b26ee15 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:31:40 -0600 Subject: [PATCH 07/11] feat(qt): Shift ranges in the library folders settings table A row or checkbox click toggled one folder. Shift now adds the run over the rows the filter shows, Ctrl adds one, and the ids stay numbers for the removal action. cc --- .../qml/settings/LibraryFolderTable.qml | 30 +++++++++++++++---- scripts/qml-tests/tst_multi_select.qml | 23 ++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/qbz-qt/qml/settings/LibraryFolderTable.qml b/crates/qbz-qt/qml/settings/LibraryFolderTable.qml index 08dc5c00e..6375399b2 100644 --- a/crates/qbz-qt/qml/settings/LibraryFolderTable.qml +++ b/crates/qbz-qt/qml/settings/LibraryFolderTable.qml @@ -42,10 +42,23 @@ Column { }) } function isSelected(id) { return selectedIds.indexOf(id) >= 0 } - function toggleSelected(id) { - const next = selectedIds.slice() - const at = next.indexOf(id) - if (at >= 0) next.splice(at, 1); else next.push(id) + /// A plain or Ctrl click toggles one folder; Shift adds the range from the + /// last plain click over the rows SHOWN (the filter is a view filter) — + /// the rule every multi-select list shares (controls/SelectionModel.qml). + SelectionModel { id: folderSel } + function toggleSelected(id, mods) { + const rows = root.shown() + const current = {} + for (let i = 0; i < selectedIds.length; i++) + current[String(selectedIds[i])] = true + const picked = folderSel.next(current, id, rows, + mods === undefined ? Qt.NoModifier : mods) + // Back to the ids themselves (the removal action takes them as + // numbers), keeping earlier picks the filter currently hides. + const next = selectedIds.filter(function (sid) { return picked[String(sid)] === true }) + for (let j = 0; j < rows.length; j++) + if (picked[String(rows[j].id)] === true && next.indexOf(rows[j].id) < 0) + next.push(rows[j].id) selectedIds = next } function scanLabel(ts) { @@ -118,6 +131,7 @@ Column { QbzBridge.settingsString("library-remove-folders", JSON.stringify(root.selectedIds)) root.selectedIds = [] + folderSel.anchorId = "" } } } @@ -279,7 +293,9 @@ Column { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.toggleSelected(folderRow.modelData.id) + onClicked: function (mouse) { + root.toggleSelected(folderRow.modelData.id, mouse.modifiers) + } } Row { @@ -292,7 +308,9 @@ Column { width: root.kioskHost ? 44 : 20 anchors.verticalCenter: parent.verticalCenter checked: root.isSelected(folderRow.modelData.id) - onToggled: root.toggleSelected(folderRow.modelData.id) + onToggled: function (mods) { + root.toggleSelected(folderRow.modelData.id, mods) + } } // FOLDER: type glyph + name. Row { diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml index d63370c59..7b353011a 100644 --- a/scripts/qml-tests/tst_multi_select.qml +++ b/scripts/qml-tests/tst_multi_select.qml @@ -13,6 +13,7 @@ import "../../crates/qbz-qt/qml/cards" import "../../crates/qbz-qt/qml/views" as Views import "../../crates/qbz-qt/qml/views/local" as Local import "../../crates/qbz-qt/qml/views/myqbz" as MyQbz +import "../../crates/qbz-qt/qml/settings" as Settings Item { id: root @@ -156,6 +157,18 @@ Item { view: railView } + // ---- Settings > Local Library folders --------------------------------------- + Settings.LibraryFolderTable { + id: folderTable + x: 0; y: 880 + width: 1200 + lib: ({ "folders": [1, 2, 3, 4, 5].map(function (n) { + return { "id": n, "displayName": "Music " + n, "path": "/music/" + n, + "isNetwork": false, "enabled": true, "accessible": true, + "status": "active", "lastScan": 0 } + }) }) + } + TestCase { name: "MultiSelect" when: windowShown @@ -225,6 +238,7 @@ Item { QbzLocal.treeCalls = [] railView.opened = [] railView.treeSelectMode = true + folderTable.selectedIds = [] } // A point of the row with nothing drawn over it but the body: the @@ -376,6 +390,15 @@ Item { clickOn(railCheck("/m/b"), 6, 6, Qt.ShiftModifier) compare(QbzLocal.treeCalls[3], "folder:/m/b") } + + function test_settings_folder_table_shift_range() { + var first = textItem(folderTable, "Music 1") + clickOn(first, 10, first.height / 2) + var fourth = textItem(folderTable, "Music 4") + clickOn(fourth, 10, fourth.height / 2, Qt.ShiftModifier) + compare(folderTable.selectedIds.slice().sort().join(","), "1,2,3,4") + verify(folderTable.isSelected(2), "the numeric ids stay numbers") + } } // ---- the selection rule itself --------------------------------------------- From ad07d8c14e1111ac9c5c37e0aa126b6e82af3d02 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:31:48 -0600 Subject: [PATCH 08/11] 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 --- crates/qbz-qt/qml/controls/SelectionModel.qml | 35 +++++++++++++++ crates/qbz-qt/qml/views/LocalLibraryView.qml | 44 +++++++++++++------ scripts/qml-tests/tst_multi_select.qml | 19 ++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/crates/qbz-qt/qml/controls/SelectionModel.qml b/crates/qbz-qt/qml/controls/SelectionModel.qml index 8551010ed..1563602bc 100644 --- a/crates/qbz-qt/qml/controls/SelectionModel.qml +++ b/crates/qbz-qt/qml/controls/SelectionModel.qml @@ -118,4 +118,39 @@ QtObject { 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 + } } diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index a7a6fed69..468a06e88 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -1187,23 +1187,42 @@ Rectangle { return sortRows(rows, genresSort) } + // The Explorer facets follow the spreadsheet rule — a plain click picks + // one value, Ctrl adds or removes one, Shift picks the run from the last + // click, Ctrl+Shift adds that run (SelectionModel.nextExclusive) — each + // column over its values AS LISTED (sorted, and filtered by its own + // search box). One anchor per column; a column whose values are rebuilt + // by a change upstream starts without one. + SelectionModel { id: genreFacetSel; idKey: "key" } + SelectionModel { id: yearFacetSel; idKey: "key" } + SelectionModel { id: artistFacetSel; idKey: "key" } + SelectionModel { id: albumFacetSel; idKey: "key" } function toggleGenre(key, modifiers) { - selectedGenres = nextFacetSelection(selectedGenres, key, modifiers) + selectedGenres = nextFacetSelection(genreFacetSel, selectedGenres, key, modifiers, genreNames) selectedGenreYears = ({}) selectedGenreArtists = ({}) selectedGenreAlbums = ({}) + yearFacetSel.anchorId = "" + artistFacetSel.anchorId = "" + albumFacetSel.anchorId = "" } function toggleGenreYear(key, modifiers) { - selectedGenreYears = nextFacetSelection(selectedGenreYears, key, modifiers) + selectedGenreYears = nextFacetSelection(yearFacetSel, selectedGenreYears, key, modifiers, + genreYearOptions) selectedGenreArtists = ({}) selectedGenreAlbums = ({}) + artistFacetSel.anchorId = "" + albumFacetSel.anchorId = "" } function toggleGenreArtist(key, modifiers) { - selectedGenreArtists = nextFacetSelection(selectedGenreArtists, key, modifiers) + selectedGenreArtists = nextFacetSelection(artistFacetSel, selectedGenreArtists, key, + modifiers, genreArtistOptions) selectedGenreAlbums = ({}) + albumFacetSel.anchorId = "" } function toggleGenreAlbum(key, modifiers) { - selectedGenreAlbums = nextFacetSelection(selectedGenreAlbums, key, modifiers) + selectedGenreAlbums = nextFacetSelection(albumFacetSel, selectedGenreAlbums, key, modifiers, + genreAlbumOptions) } function setExplorerColumns(mode) { if (["genre", "year", "both"].indexOf(mode) < 0 || mode === explorerColumns) @@ -1219,17 +1238,14 @@ Rectangle { readonly property int explorerFacetCount: explorerColumns === "both" ? 4 : 3 readonly property int explorerLeadingCount: explorerColumns === "year" ? genreYearOptions.length : genreNames.length - function nextFacetSelection(current, key, modifiers) { - if (key === "") return ({}) - var additive = (modifiers & Qt.ControlModifier) !== 0 - || (modifiers & Qt.MetaModifier) !== 0 - if (!additive) { - var only = {}; only[key] = true; return only + /// `key` "" is the column's "All" row: it clears the column. + function nextFacetSelection(rule, current, key, modifiers, options) { + if (key === "") { + rule.anchorId = "" + return ({}) } - var out = Object.assign({}, current) - if (out[key]) delete out[key] - else out[key] = true - return out + return rule.nextExclusive(current, key, options || [], + modifiers === undefined ? Qt.NoModifier : modifiers) } // The selected artist's albums. diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml index 7b353011a..1ebd62e35 100644 --- a/scripts/qml-tests/tst_multi_select.qml +++ b/scripts/qml-tests/tst_multi_select.qml @@ -432,5 +432,24 @@ Item { m = keyedRule.next(m, 7, rows, Qt.ShiftModifier) compare(Object.keys(m).sort().join(","), "7,8,9") } + + function test_exclusive_rule() { + var rows = [{ "id": "g1" }, { "id": "g2" }, { "id": "g3" }, { "id": "g4" }, { "id": "g5" }] + var m = rule.nextExclusive({}, "g2", rows, Qt.NoModifier) + compare(Object.keys(m).sort().join(","), "g2", "a plain click selects only that row") + m = rule.nextExclusive(m, "g4", rows, Qt.ShiftModifier) + compare(Object.keys(m).sort().join(","), "g2,g3,g4", "Shift selects the range") + m = rule.nextExclusive(m, "g1", rows, Qt.ShiftModifier) + compare(Object.keys(m).sort().join(","), "g1,g2", "the anchor stays, Shift replaces the range") + m = rule.nextExclusive(m, "g5", rows, Qt.ControlModifier) + compare(Object.keys(m).sort().join(","), "g1,g2,g5", "Ctrl adds one") + m = rule.nextExclusive(m, "g5", rows, Qt.ControlModifier) + compare(Object.keys(m).sort().join(","), "g1,g2", "Ctrl on a selected row removes it") + m = rule.nextExclusive(m, "g3", rows, Qt.ControlModifier) + m = rule.nextExclusive(m, "g5", rows, Qt.ControlModifier | Qt.ShiftModifier) + compare(Object.keys(m).sort().join(","), "g1,g2,g3,g4,g5", "Ctrl+Shift adds a range") + m = rule.nextExclusive(m, "g4", rows, Qt.NoModifier) + compare(Object.keys(m).sort().join(","), "g4", "a plain click starts over") + } } } From b63264d132a6d86c2bd4597197f6c52000d41f11 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:31:54 -0600 Subject: [PATCH 09/11] fix(qt): Ctrl+A and Escape reach the offline manager, and Escape leaves mix select mode The AppShell hotkey seam duck-types selectAll, exitMultiSelectMode and multiSelectOn on the mounted view. The offline manager exposed none of them, so Ctrl+A did nothing there although the Slint ticked every track, and MixView lacked multiSelectOn, so Escape never left its select mode. The seam's comment, which still listed Artist, Playlist, Mix and Label as views without multi-select, now names the views that implement it. cc --- crates/qbz-qt/qml/shell/AppShell.qml | 9 +++++---- crates/qbz-qt/qml/views/MixView.qml | 5 ++++- crates/qbz-qt/qml/views/OfflineManagerView.qml | 12 ++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 9f9806163..885c04551 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -244,10 +244,11 @@ Rectangle { // in-view QML JS (library_bulk.rs:8: "select-all / clear never reach // Rust"), so the QbzShell signals route to the mounted view's // duck-typed interface: selectAll() / exitMultiSelectMode() / - // multiSelectOn. Implemented by LibraryView, LocalLibraryView and - // LocalAlbumView (MyQbzDetailView is EXIT-ONLY — its selection lives in - // Rust with no select-all arm); views without multi-select (Artist, - // Playlist, Mix, Label, Offline) match nothing — PARITY-DEBT (K4). + // multiSelectOn. Implemented by every view with a multi-select list: + // Library, Local Library, Local Album, Album, Artist, Playlist, Mix, + // Label and the Offline manager. MyQbzDetailView is EXIT-ONLY — its + // selection lives in Rust and its bar has no select-all arm (the Slint + // never had one there either). Connections { target: QbzShell function onSelectAllRequested() { diff --git a/crates/qbz-qt/qml/views/MixView.qml b/crates/qbz-qt/qml/views/MixView.qml index 294fbd1dd..a936422f3 100644 --- a/crates/qbz-qt/qml/views/MixView.qml +++ b/crates/qbz-qt/qml/views/MixView.qml @@ -124,7 +124,10 @@ Rectangle { if (action !== "add-to-playlist" && action !== "add-to-mixtape") root.selected = ({}) } - // Ctrl+A / Escape hotkey seam (AppShell duck-types these). + // Ctrl+A / Escape hotkey seam (AppShell duck-types these). Without + // `multiSelectOn` the Escape stack never saw this view in select mode, so + // Escape did not leave it here while it did on every other track list. + readonly property bool multiSelectOn: root.multiSelect function selectAll() { if (!root.multiSelect) root.setMultiSelect(true) root.bulkAction("select-all") diff --git a/crates/qbz-qt/qml/views/OfflineManagerView.qml b/crates/qbz-qt/qml/views/OfflineManagerView.qml index 1c71de716..952f80d15 100644 --- a/crates/qbz-qt/qml/views/OfflineManagerView.qml +++ b/crates/qbz-qt/qml/views/OfflineManagerView.qml @@ -119,6 +119,18 @@ Rectangle { mods === undefined ? Qt.NoModifier : mods) } + // Ctrl+A / Escape hotkey seam (AppShell duck-types these). The selection + // here is always on (no select mode), so Ctrl+A ticks every track — as + // the Slint's select_all_active_surface did for this view — and Escape + // drops a non-empty selection. + readonly property bool multiSelectOn: Object.keys(root.selected).length > 0 + function selectAll() { + root.bulkAction("select-all") + } + function exitMultiSelectMode() { + root.bulkAction("clear") + } + function bulkAction(id) { if (id === "clear") { root.selected = ({}) From e8f4dd4aa37eac6d8de77320eb8664b50e7c1e90 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:42:35 -0600 Subject: [PATCH 10/11] build(qt): each worktree builds, and keeps its binary, in its own target This workstation configures ONE Cargo target outside the checkouts for every worktree below the project directory. A build in any worktree therefore replaced the `qbz` another worktree had just built: a parallel session's release build in its own worktree replaced the binary under test in the main one. scripts/qt-target.py now decides where builds go. When the target Cargo resolves lies outside the worktree and nothing pins it (CARGO_TARGET_DIR or CARGO_BUILD_TARGET_DIR), the worktree builds in its own sibling directory, `-worktrees/`; rebuilding the same worktree still replaces its own binary. Cargo's default target inside the checkout (macOS, CI, Windows) and explicit overrides are untouched, and no machine path is committed. Two checkouts that share a directory name get a stable suffix instead of one target. qt-run.sh, the runtime gate and direct qt-cargo.py builds and tests all use it. qt-run.sh prints the target and the binary path, and re-points crates/target after each successful build, never over a real directory. The aarch64 scripts build into and install from the same target, and the qbzd acceptance script names the build command that lands its binary there. The first build of a profile in a new worktree target is seeded from the shared target with copy-on-write clones, skipping incremental caches: Cargo rewrites moved build-script paths through root-output (custom_build.rs, "rewrite paths if the target directory has been moved"), so unchanged crates are not compiled again and no data is duplicated. Without clone support nothing is copied and Cargo builds from scratch. The resolution and shortcut handling start from the uncommitted draft in the build-storage-layout worktree. Verified on the main worktree: seeded from the shared release target, Cargo found everything fresh in two seconds, and crates/target now points at the worktree's own target. 33 build-gate tests cover derivation, overrides, name collisions, linking, seeding and the runner end to end. cc --- scripts/build-aarch64-linux.sh | 5 +- scripts/build-aarch64-qbzd.sh | 12 +- scripts/qbzd-acceptance.sh | 2 +- scripts/qt-cargo.py | 50 ++++++++ scripts/qt-run.sh | 37 +++++- scripts/qt-runtime-gate.sh | 8 +- scripts/qt-target.py | 213 +++++++++++++++++++++++++++++++ scripts/test_qt_build_gates.py | 221 +++++++++++++++++++++++++++++++++ 8 files changed, 541 insertions(+), 7 deletions(-) create mode 100644 scripts/qt-target.py diff --git a/scripts/build-aarch64-linux.sh b/scripts/build-aarch64-linux.sh index ab125b76f..ccdc40110 100755 --- a/scripts/build-aarch64-linux.sh +++ b/scripts/build-aarch64-linux.sh @@ -89,8 +89,11 @@ case "$arch" in else jobs="$(nproc)"; fi say "RAM ${mem_mb} MB -> CARGO_BUILD_JOBS=${jobs}" # No RUSTFLAGS, no mold, stable: same cache rule as qt-run.sh. + # The target qt-cargo.py builds in (scripts/qt-target.py), resolved once. + CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)" + export CARGO_TARGET_DIR CARGO_BUILD_JOBS="$jobs" CARGO_INCREMENTAL=0 python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbz-qt - install -Dm755 "crates/target/release/qbz" "$OUT" + install -Dm755 "$CARGO_TARGET_DIR/release/qbz" "$OUT" ;; x86_64 | amd64) die "CROSS mode is not supported for the Qt binary (see the header: cxx-qt \ diff --git a/scripts/build-aarch64-qbzd.sh b/scripts/build-aarch64-qbzd.sh index 590724d3b..61adf50a9 100755 --- a/scripts/build-aarch64-qbzd.sh +++ b/scripts/build-aarch64-qbzd.sh @@ -48,8 +48,12 @@ case "$arch" in else echo "[qbzd-aarch64] non-apt distro: install the equivalents of: ${DEPS[*]}" >&2 fi + # This worktree's own target (scripts/qt-target.py): Cargo's default in a + # plain checkout, a per-worktree sibling when the host shares one target. + CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)" + export CARGO_TARGET_DIR ( cd crates && cargo build --release -p qbzd ) - install -Dm755 "crates/target/release/qbzd" "$OUT" + install -Dm755 "$CARGO_TARGET_DIR/release/qbzd" "$OUT" ;; x86_64 | amd64) echo "[qbzd-aarch64] CROSS-compile from $arch via cross (Docker)" @@ -65,8 +69,12 @@ case "$arch" in export CROSS_CONTAINER_OPTS="${CROSS_CONTAINER_OPTS:---memory=8g --memory-swap=12g}" # crates/Cross.toml injects the arm64 dev libs into the image (the daemon's # set; the Qt desktop is not cross-built, see build-aarch64-linux.sh). + # cross mounts CARGO_TARGET_DIR into the container: the worktree's own + # target here too, never one another worktree also builds into. + CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)" + export CARGO_TARGET_DIR ( cd crates && cross build --release --target "$TARGET" -p qbzd ) - install -Dm755 "crates/target/$TARGET/release/qbzd" "$OUT" + install -Dm755 "$CARGO_TARGET_DIR/$TARGET/release/qbzd" "$OUT" ;; *) echo "[qbzd-aarch64] ERROR: unsupported build host arch: $arch" >&2 diff --git a/scripts/qbzd-acceptance.sh b/scripts/qbzd-acceptance.sh index 93df65c03..25751d4c3 100755 --- a/scripts/qbzd-acceptance.sh +++ b/scripts/qbzd-acceptance.sh @@ -31,7 +31,7 @@ fail() { echo "FAIL: $1" >&2; exit 1; } command -v curl >/dev/null 2>&1 || fail "curl is required" command -v python3 >/dev/null 2>&1 || fail "python3 is required (status/ping/info shape checks)" command -v timeout >/dev/null 2>&1 || fail "timeout (coreutils) is required" -[ -x "$QBZD_BIN" ] || fail "qbzd binary not found/executable at $QBZD_BIN -- build it first (release, on its own: cargo build --release -p qbzd)" +[ -x "$QBZD_BIN" ] || fail "qbzd binary not found/executable at $QBZD_BIN -- build it first (release, on its own, into this worktree's target: python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbzd)" # --------------------------------------------------------------------------- # Isolated scratch profile root. NEVER the real daemon/desktop roots: dirs:: diff --git a/scripts/qt-cargo.py b/scripts/qt-cargo.py index acbc057cd..86b7a616e 100644 --- a/scripts/qt-cargo.py +++ b/scripts/qt-cargo.py @@ -12,9 +12,15 @@ Use this entry point for Qt builds/tests, including direct Cargo invocations: No target cleanup, profile changes, or RUSTFLAGS: a content-derived, unused C++ define invalidates cc-rs consumers when the SDK changes, in either profile and with any Rust toolchain. Existing CXXFLAGS (including MSVC flags) survive. + +Where Cargo builds follows scripts/qt-target.py: a host-level target shared by +every worktree becomes this worktree's own sibling of it, so a direct build or +test here cannot replace a binary another worktree built. An explicit +CARGO_TARGET_DIR, or a target inside the checkout, is left to Cargo. """ import hashlib +import importlib.util import os from pathlib import Path import re @@ -112,12 +118,56 @@ def cargo_environment(env): return result, version, fingerprint +def load_targets(): + # A hyphenated sibling loaded by path: without this, every build would leave + # an untracked scripts/__pycache__ in the checkout. + sys.dont_write_bytecode = True + spec = importlib.util.spec_from_file_location( + "qt_target", Path(__file__).with_name("qt-target.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def manifest_argument(arguments): + for index, argument in enumerate(arguments): + if argument == "--manifest-path" and index + 1 < len(arguments): + return Path(arguments[index + 1]) + if argument.startswith("--manifest-path="): + return Path(argument.split("=", 1)[1]) + return None + + +def target_environment(env, arguments, targets=None): + """`env` with CARGO_TARGET_DIR set to this worktree's own target, when the + target Cargo would use is shared with other worktrees (qt-target.py).""" + targets = targets or load_targets() + if targets.explicit(env): + return env + try: + metadata = targets.cargo_metadata(manifest_argument(arguments), dict(env)) + configured = Path(metadata["target_directory"]).resolve() + target = targets.worktree_target( + configured, targets.worktree_root(metadata["workspace_root"]), env) + except (OSError, subprocess.SubprocessError, KeyError, ValueError) as error: + print(f"[qt-cargo] target not resolved ({error}); Cargo's own choice stands", + file=sys.stderr, flush=True) + return env + if target == configured: + return env + result = dict(env) + result["CARGO_TARGET_DIR"] = str(target) + print(f"[qt-cargo] worktree target {target}", file=sys.stderr, flush=True) + return result + + def main(): if len(sys.argv) < 2: raise RuntimeError("usage: qt-cargo.py [arguments...]") env, version, fingerprint = cargo_environment(os.environ) print(f"[qt-cargo] Qt {version}, SDK {fingerprint}, qmake={env['QMAKE']}", file=sys.stderr, flush=True) + env = target_environment(env, sys.argv[2:]) # subprocess.run, NOT os.execvpe: on Windows os.exec* is emulated (spawn + # terminate) and segfaulted here — the release-windows build died with # "Segmentation fault ... exit code 139" right after this line on diff --git a/scripts/qt-run.sh b/scripts/qt-run.sh index b373f335f..6cccaef70 100755 --- a/scripts/qt-run.sh +++ b/scripts/qt-run.sh @@ -33,6 +33,16 @@ # run BEFORE the build — failing in one second beats failing after two minutes, # or worse, at the owner's smoke. NO_AUDIT=1 skips them. # +# ─── EACH WORKTREE BUILDS IN ITS OWN TARGET ───────────────────────────────── +# scripts/qt-target.py decides where. With Cargo's default target (inside the +# checkout: macOS, CI) nothing changes. When the host configures ONE target +# outside the checkout for every worktree, this worktree builds in its own +# sibling of it, `-worktrees/`, so a build in another worktree +# can never replace the binary built here; rebuilding THIS worktree still does. +# Its first build of a profile is seeded from the shared target with +# copy-on-write clones, and crates/target is re-pointed after each successful +# build. An explicit CARGO_TARGET_DIR is used exactly as given. +# # Usage: ./scripts/qt-run.sh [extra app args] # DEBUG=1 ./scripts/qt-run.sh # debug profile (the binary the gate uses) # NORUN=1 ./scripts/qt-run.sh # build only, don't exec @@ -127,7 +137,6 @@ if [[ "${DEBUG:-0}" == 1 ]]; then else PROFILE=release; PROFILE_ARGS=(--release) fi -BIN="${CARGO_TARGET_DIR:-crates/target}/${PROFILE}/qbz" # --- ONE BUILD AT A TIME, BOX-WIDE ------------------------------------------ # Not paranoia: a Slint `qbz-ui` rustc in another worktree peaks 20-30 GB on a @@ -141,6 +150,13 @@ if [[ "${FORCE:-0}" != 1 ]]; then fi fi +# --- Where this worktree builds (see the header) ----------------------------- +# The PHYSICAL path: moc/cc-rs resolve includes wrongly through the crates/target +# symlink. +CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)" || die "could not resolve the Cargo target directory" +export CARGO_TARGET_DIR +BIN="${CARGO_TARGET_DIR}/${PROFILE}/qbz" + # --- The QML audits (fail fast; cargo cannot see QML) ------------------------ if [[ "${NO_AUDIT:-0}" != 1 ]]; then if [[ -d "${AUDIT_DIR}" ]] && command -v python3 >/dev/null 2>&1; then @@ -198,6 +214,17 @@ eta_secs=0 avail_mb=$(mem_avail_mb) say "profile=${PROFILE} jobs=${CARGO_BUILD_JOBS} avail=${avail_mb}MB rustflags='${RUSTFLAGS:-}'" +say "target=${CARGO_TARGET_DIR}" + +# A worktree target's first build of a profile starts from the shared target's +# artifacts (copy-on-write, nothing duplicated) instead of from nothing. After +# the one-build guard on purpose: never clone a target another build is writing. +seed_profiles=("${PROFILE}") +[[ "${TEST:-0}" == 1 && "${PROFILE}" != debug ]] && seed_profiles+=(debug) +if [[ -z "$(other_builds)" ]]; then + python3 scripts/qt-target.py seed --target-dir "${CARGO_TARGET_DIR}" "${seed_profiles[@]}" \ + || warn "could not seed ${CARGO_TARGET_DIR}; Cargo builds it from scratch" +fi # --- Start banner ------------------------------------------------------------ build_start=$(date +%s) @@ -232,6 +259,11 @@ fi # --- The build --------------------------------------------------------------- python3 scripts/qt-cargo.py build "${PROFILE_ARGS[@]}" --manifest-path crates/Cargo.toml -p qbz-qt +# The familiar crates/target path follows the target this worktree just built, +# and only after a successful build. +python3 scripts/qt-target.py link --target-dir "${CARGO_TARGET_DIR}" \ + || warn "crates/target was not re-pointed; the binary is ${BIN}" + # --- Stop the ticker, record the duration, print the final banner ------------ [[ -n "${tick_pid}" ]] && { kill "${tick_pid}" 2>/dev/null || true; wait "${tick_pid}" 2>/dev/null || true; } trap - EXIT @@ -239,6 +271,7 @@ build_secs=$(( $(date +%s) - build_start )) mkdir -p "${eta_dir}" 2>/dev/null && printf '%s\n' "${build_secs}" > "${eta_file}" 2>/dev/null || true printf '%s[qt-run] ✔ build finished %s · took %s (%s)%s\n' \ "${C_BOLD}${C_GRN}" "$(date '+%H:%M:%S')" "$(fmt_dur "${build_secs}")" "${PROFILE}" "${C_RST}" +say "binary=${BIN}" # --- Tests (opt-in) ---------------------------------------------------------- if [[ "${TEST:-0}" == 1 ]]; then @@ -260,7 +293,7 @@ if [[ "${SMOKE:-0}" == 1 ]]; then # template path so the two agree. log="$(mktemp "${TMPDIR:-/tmp}/qbz-qt-smoke-XXXXXX")" say "offscreen smoke (75s max) → ${log}" - QT_QPA_PLATFORM=offscreen RUST_LOG=info run_timeout 75 "./${BIN}" > "${log}" 2>&1 || true + QT_QPA_PLATFORM=offscreen RUST_LOG=info run_timeout 75 "${BIN}" > "${log}" 2>&1 || true # A log this short means the process never really started (the macOS # `timeout`-not-found case did exactly that). Zero complaints in an empty log # is not a pass. diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index 235a89e26..26ad26890 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -3,6 +3,10 @@ set -euo pipefail cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." export QBZ_PREBUILT_SHADERS=1 +# The same target qt-run.sh builds in: this worktree's own when the host shares +# one target across worktrees, Cargo's default otherwise (scripts/qt-target.py). +CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)" +export CARGO_TARGET_DIR bash scripts/test-search-local-qml.sh bash scripts/test-updates-qml.sh bash scripts/test-playback-cache-qml.sh @@ -23,7 +27,7 @@ node scripts/test_qt_kiosk_navigation.mjs node scripts/test_qt_kiosk_feedback.mjs node scripts/test_qt_exclusive_gate.mjs python3 scripts/qt-cargo.py test --manifest-path crates/Cargo.toml -p qbz-qt --no-fail-fast -target_dir="${CARGO_TARGET_DIR:-$PWD/crates/target}" +target_dir="$CARGO_TARGET_DIR" logs="$(mktemp -d "${TMPDIR:-/tmp}/qbz-qt-gate-XXXXXX")" printf '[qt-gate] startup logs: %s\n' "$logs" for profile in debug release; do @@ -32,5 +36,7 @@ for profile in debug release; do python3 scripts/qt-cargo.py build "${args[@]}" --manifest-path crates/Cargo.toml -p qbz-qt python3 scripts/qt-smoke.py "$target_dir/$profile/qbz" --log "$logs/$profile.log" done +python3 scripts/qt-target.py link --target-dir "$target_dir" \ + || printf '[qt-gate] crates/target not re-pointed; binaries are in %s\n' "$target_dir" >&2 # A healthy offscreen bus cannot expose Qt's synchronous xcb D-Bus startup. python3 scripts/qt-smoke.py "$target_dir/release/qbz" --silent-bus --log "$logs/release-silent-bus.log" diff --git a/scripts/qt-target.py b/scripts/qt-target.py new file mode 100644 index 000000000..a9384f0fd --- /dev/null +++ b/scripts/qt-target.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Each worktree's own physical Cargo target, and its crates/target shortcut. + +A host may put Cargo's target OUTSIDE the checkout, on a build disk shared by +every worktree below one project directory. Then every worktree compiled into +the SAME directory, and a build in any of them replaced the `qbz` binary another +worktree had just built and was running or smoke-testing (2026-09-14: a parallel +session's release build replaced the binary under test in the main worktree). + +So when the target Cargo resolves lies outside the worktree, and nobody pinned it +explicitly with CARGO_TARGET_DIR or CARGO_BUILD_TARGET_DIR, the worktree builds +in its own sibling directory instead: + + -worktrees/ + +Building the same worktree again still replaces its own binary; other worktrees +keep theirs. A target inside the checkout (Cargo's default: macOS, CI, Windows) +and an explicit override are returned unchanged. No machine-specific path lives +here: Cargo configuration and the environment choose the storage. + +The first build of a profile in a new worktree target is seeded from the shared +target with copy-on-write clones (`cp --reflink=always`), so the registry crates +that did not change are not compiled again and no data is duplicated; where the +filesystem cannot clone, nothing is copied and Cargo simply builds from scratch. +Never remove a real target directory to make room for a symlink. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import uuid + +EXPLICIT_ENV = ("CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR") +MARKER = ".qbz-worktree" +SUFFIX = "-worktrees" +# Workspace crates recompile per worktree anyway (their metadata hash includes +# the checkout path), and incremental caches are only kept for those. +SEED_SKIP = {"incremental"} + + +def cargo_metadata(manifest=None, env=None): + command = ["cargo", "metadata", "--no-deps", "--format-version", "1"] + if manifest is not None: + command += ["--manifest-path", str(manifest)] + return json.loads(subprocess.check_output(command, text=True, env=env)) + + +def worktree_root(workspace_root): + workspace_root = Path(workspace_root) + try: + top = subprocess.check_output( + ["git", "-C", str(workspace_root), "rev-parse", "--show-toplevel"], + text=True, stderr=subprocess.DEVNULL).strip() + if top: + return Path(top).resolve() + except (OSError, subprocess.SubprocessError): + pass + # This repository keeps its Cargo workspace in crates/. + return workspace_root.resolve().parent + + +def is_inside(path, root): + try: + Path(path).relative_to(root) + return True + except ValueError: + return False + + +def explicit(env): + return any(env.get(name) for name in EXPLICIT_ENV) + + +def worktree_target(configured, root, env): + """The target this worktree builds in (see the module docstring).""" + configured = Path(configured).resolve() + root = Path(root).resolve() + if explicit(env) or is_inside(configured, root): + return configured + name = re.sub(r"[^A-Za-z0-9._-]+", "-", root.name).strip("-") or "worktree" + candidate = configured.parent / f"{configured.name}{SUFFIX}" / name + try: + claimed = (candidate / MARKER).read_text().strip() + except OSError: + claimed = "" + if claimed and claimed != str(root): + # Two checkouts with the same directory name: the second one gets a + # stable suffix instead of sharing (and overwriting) the first. + digest = hashlib.sha1(str(root).encode()).hexdigest()[:8] + candidate = candidate.with_name(f"{name}-{digest}") + return candidate + + +def shared_target(manifest=None, env=None): + """Cargo's configured target with any explicit override removed.""" + env = dict(os.environ if env is None else env) + for name in EXPLICIT_ENV: + env.pop(name, None) + return Path(cargo_metadata(manifest, env)["target_directory"]).resolve() + + +def resolve_target(manifest=None, env=None): + env = os.environ if env is None else env + metadata = cargo_metadata(manifest, dict(env)) + root = worktree_root(metadata["workspace_root"]) + return worktree_target(metadata["target_directory"], root, env) + + +def is_worktree_target(target, shared): + target = Path(target).resolve() + shared = Path(shared).resolve() + return target.parent == shared.parent / f"{shared.name}{SUFFIX}" + + +def link_target(manifest, target, root=None): + target = Path(target).resolve() + alias = Path(manifest).resolve().parent / "target" + if not target.is_dir(): + raise RuntimeError(f"build target does not exist: {target}") + if root is not None and target.parent.name.endswith(SUFFIX): + # Claim this worktree target for its checkout (see worktree_target). + (target / MARKER).write_text(f"{Path(root).resolve()}\n") + if alias.resolve() == target: + return + if alias.exists() and not alias.is_symlink(): + raise RuntimeError(f"preserving existing target directory: {alias}; migrate it before linking") + if os.name == "nt": + # A normal Windows Cargo target already uses alias. External targets + # must remain usable without requiring symlink privileges in CI. + print(f"[qt-target] external target: {target} (shortcut not created on Windows)", file=sys.stderr) + return + temporary = alias.with_name(f".target-link-{uuid.uuid4().hex}") + try: + temporary.symlink_to(target, target_is_directory=True) + temporary.replace(alias) + finally: + temporary.unlink(missing_ok=True) + + +def seed_target(target, shared, profiles, clone=None): + """Clone each missing profile directory of `shared` into `target`. + + Returns the profiles seeded. A profile already present in `target`, absent + from `shared`, or that the filesystem cannot clone is left alone. + """ + target = Path(target) + shared = Path(shared) + clone = clone or reflink_clone + seeded = [] + if target.resolve() == shared.resolve() or not is_worktree_target(target, shared): + return seeded + for profile in profiles: + source = shared / profile + destination = target / profile + if destination.exists() or not source.is_dir(): + continue + target.mkdir(parents=True, exist_ok=True) + staging = target / f".seed-{profile}-{uuid.uuid4().hex}" + staging.mkdir() + try: + entries = sorted(entry for entry in source.iterdir() if entry.name not in SEED_SKIP) + if entries: + clone(entries, staging) + # Appears whole or not at all: an interrupted clone never looks + # like a finished target to Cargo. + staging.rename(destination) + seeded.append(profile) + except (OSError, subprocess.SubprocessError) as error: + print(f"[qt-target] not seeding {profile} ({error}); Cargo builds it from scratch", file=sys.stderr) + finally: + if staging.exists(): + shutil.rmtree(staging, ignore_errors=True) + return seeded + + +def reflink_clone(entries, destination): + if not sys.platform.startswith("linux"): + raise OSError("copy-on-write clones are only used with GNU cp") + subprocess.run(["cp", "-a", "--reflink=always", *map(str, entries), str(destination)], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("operation", choices=["resolve", "link", "seed"]) + parser.add_argument("profiles", nargs="*", help="seed: profile directories, e.g. release debug") + parser.add_argument("--manifest-path", default="crates/Cargo.toml", type=Path) + parser.add_argument("--target-dir", type=Path) + args = parser.parse_args() + if args.operation == "resolve": + print(args.target_dir.resolve() if args.target_dir else resolve_target(args.manifest_path)) + return + target = args.target_dir.resolve() if args.target_dir else resolve_target(args.manifest_path) + if args.operation == "link": + root = worktree_root(Path(args.manifest_path).resolve().parent) + link_target(args.manifest_path, target, root) + return + shared = shared_target(args.manifest_path) + for profile in seed_target(target, shared, args.profiles or ["release"]): + print(f"[qt-target] seeded {profile} from {shared / profile}", file=sys.stderr) + + +if __name__ == "__main__": + try: + main() + except (OSError, RuntimeError, subprocess.SubprocessError, KeyError, ValueError) as error: + print(f"[qt-target] {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/test_qt_build_gates.py b/scripts/test_qt_build_gates.py index b0f4dae71..b77389376 100644 --- a/scripts/test_qt_build_gates.py +++ b/scripts/test_qt_build_gates.py @@ -1,9 +1,14 @@ #!/usr/bin/env python3 """SDK-cache and crash-aware smoke regressions; no Qt or Cargo build needed.""" +import contextlib import importlib.util +import io +import json import os from pathlib import Path +import shutil +import subprocess import tempfile import unittest import sys @@ -21,6 +26,222 @@ def load(name, filename): qt = load("qt_cargo", "qt-cargo.py") smoke = load("qt_smoke", "qt-smoke.py") +targets = load("qt_target", "qt-target.py") + + +@unittest.skipIf(os.name == "nt", "unprivileged Windows symlinks may be unavailable") +class TargetTests(unittest.TestCase): + """Each worktree keeps its own binaries when the host shares one target.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.worktree = self.root / "checkouts" / "main-tree" + self.crates = self.worktree / "crates" + self.crates.mkdir(parents=True) + self.manifest = self.crates / "Cargo.toml" + self.manifest.touch() + self.shared = self.root / "nvme" / "qbz" + (self.shared / "release" / "deps").mkdir(parents=True) + (self.shared / "release" / "incremental").mkdir() + (self.shared / "release" / "deps" / "libdep.rlib").write_bytes(b"registry crate") + (self.shared / "release" / "incremental" / "session").write_bytes(b"workspace cache") + (self.shared / "release" / "qbz").write_bytes(b"another worktree's binary") + self.derived = self.root / "nvme" / "qbz-worktrees" / "main-tree" + + def test_shared_host_target_becomes_this_worktrees_own_sibling(self): + self.assertEqual(targets.worktree_target(self.shared, self.worktree, {}), self.derived) + other = self.root / "checkouts" / "codex-tree" + self.assertEqual(targets.worktree_target(self.shared, other, {}), + self.root / "nvme" / "qbz-worktrees" / "codex-tree") + + def test_explicit_override_and_checkout_target_are_used_as_given(self): + for name in targets.EXPLICIT_ENV: + with self.subTest(env=name): + self.assertEqual(targets.worktree_target(self.shared, self.worktree, {name: "/pinned"}), + self.shared) + inside = self.crates / "target" + self.assertEqual(targets.worktree_target(inside, self.worktree, {}), inside) + + def test_two_checkouts_with_one_directory_name_never_share_a_target(self): + self.derived.mkdir(parents=True) + (self.derived / targets.MARKER).write_text(str(self.root / "elsewhere" / "main-tree") + "\n") + second = targets.worktree_target(self.shared, self.worktree, {}) + self.assertNotEqual(second, self.derived) + self.assertTrue(second.name.startswith("main-tree-")) + self.assertEqual(second, targets.worktree_target(self.shared, self.worktree, {})) + (self.derived / targets.MARKER).write_text(str(self.worktree) + "\n") + self.assertEqual(targets.worktree_target(self.shared, self.worktree, {}), self.derived) + + def test_resolve_reads_cargo_metadata_for_the_manifest(self): + metadata = {"target_directory": str(self.shared), "workspace_root": str(self.crates)} + with patch.object(targets, "cargo_metadata", return_value=metadata) as cargo, \ + patch.object(targets, "worktree_root", return_value=self.worktree): + self.assertEqual(targets.resolve_target(self.manifest, {}), self.derived) + self.assertEqual(cargo.call_args.args[0], self.manifest) + + def test_link_keeps_the_familiar_path_and_claims_the_worktree_target(self): + (self.derived / "release").mkdir(parents=True) + (self.derived / "release" / "qbz").write_bytes(b"this worktree's binary") + targets.link_target(self.manifest, self.derived, self.worktree) + targets.link_target(self.manifest, self.derived, self.worktree) + alias = self.crates / "target" + self.assertTrue(alias.is_symlink()) + self.assertEqual((alias / "release" / "qbz").read_bytes(), b"this worktree's binary") + self.assertEqual((self.derived / targets.MARKER).read_text().strip(), str(self.worktree)) + # A pinned target is linked but never claimed. + targets.link_target(self.manifest, self.shared, self.worktree) + self.assertFalse((self.shared / targets.MARKER).exists()) + + def test_real_directory_is_never_removed(self): + alias = self.crates / "target" + alias.mkdir() + sentinel = alias / "keep" + sentinel.write_text("preserve") + targets.link_target(self.manifest, alias) + with self.assertRaisesRegex(RuntimeError, "preserving"): + targets.link_target(self.manifest, self.shared) + self.assertEqual(sentinel.read_text(), "preserve") + self.assertFalse(alias.is_symlink()) + + def test_old_shortcut_is_replaced_but_a_missing_build_cannot_replace_it(self): + alias = self.crates / "target" + alias.symlink_to(self.root / "old", target_is_directory=True) + with self.assertRaisesRegex(RuntimeError, "does not exist"): + targets.link_target(self.manifest, self.root / "missing") + self.assertEqual(os.readlink(alias), str(self.root / "old")) + targets.link_target(self.manifest, self.shared) + self.assertEqual(alias.resolve(), self.shared) + + @staticmethod + def copy(entries, destination): + for entry in entries: + if entry.is_dir(): + shutil.copytree(entry, destination / entry.name) + else: + shutil.copy2(entry, destination / entry.name) + + def test_seed_clones_a_missing_profile_once_and_skips_incremental(self): + self.assertEqual(targets.seed_target(self.derived, self.shared, ["release", "debug"], self.copy), + ["release"]) + self.assertEqual((self.derived / "release" / "deps" / "libdep.rlib").read_bytes(), b"registry crate") + self.assertFalse((self.derived / "release" / "incremental").exists()) + (self.derived / "release" / "qbz").write_bytes(b"this worktree's binary") + self.assertEqual(targets.seed_target(self.derived, self.shared, ["release"], self.copy), []) + self.assertEqual((self.derived / "release" / "qbz").read_bytes(), b"this worktree's binary") + self.assertEqual((self.shared / "release" / "qbz").read_bytes(), b"another worktree's binary") + + def test_seed_never_touches_pinned_targets_and_leaves_no_partial_clone(self): + pinned = self.root / "pinned" + self.assertEqual(targets.seed_target(pinned, self.shared, ["release"], self.copy), []) + self.assertFalse(pinned.exists()) + self.assertEqual(targets.seed_target(self.shared, self.shared, ["release"], self.copy), []) + + def broken(entries, destination): + (destination / "half").write_bytes(b"partial") + raise OSError("clone not supported") + + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(targets.seed_target(self.derived, self.shared, ["release"], broken), []) + self.assertEqual(list(self.derived.iterdir()), []) + + +class CargoTargetEnvironmentTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.worktree = self.root / "tree" + self.shared = self.root / "shared" / "qbz" + self.metadata = {"target_directory": str(self.shared), "workspace_root": str(self.worktree / "crates")} + for name, value in (("cargo_metadata", lambda *_: self.metadata), + ("worktree_root", lambda *_: self.worktree)): + mock = patch.object(targets, name, side_effect=value) + mock.start() + self.addCleanup(mock.stop) + + def environment(self, env, arguments=("--manifest-path", "crates/Cargo.toml")): + with contextlib.redirect_stderr(io.StringIO()): + return qt.target_environment(env, list(arguments), targets) + + def test_direct_builds_and_tests_use_the_worktree_target(self): + env = self.environment({"PATH": "/bin"}) + self.assertEqual(env["CARGO_TARGET_DIR"], str(self.root / "shared" / "qbz-worktrees" / "tree")) + self.assertEqual(targets.cargo_metadata.call_args.args[0], Path("crates/Cargo.toml")) + + def test_explicit_and_checkout_targets_are_left_to_cargo(self): + pinned = {"CARGO_TARGET_DIR": "/pinned"} + self.assertIs(self.environment(pinned), pinned) + self.metadata["target_directory"] = str(self.worktree / "crates" / "target") + plain = {"PATH": "/bin"} + self.assertIs(self.environment(plain), plain) + + def test_unresolvable_metadata_keeps_cargos_own_choice(self): + targets.cargo_metadata.side_effect = subprocess.CalledProcessError(101, "cargo") + plain = {"PATH": "/bin"} + self.assertIs(self.environment(plain), plain) + + def test_manifest_argument_forms(self): + self.assertEqual(qt.manifest_argument(["-p", "qbz-qt", "--manifest-path", "a/Cargo.toml"]), + Path("a/Cargo.toml")) + self.assertEqual(qt.manifest_argument(["--manifest-path=b/Cargo.toml"]), Path("b/Cargo.toml")) + self.assertIsNone(qt.manifest_argument(["--release"])) + + +@unittest.skipUnless(os.name == "posix" and shutil.which("bash"), "requires bash") +class RunnerTargetTests(unittest.TestCase): + def test_runner_builds_in_the_worktree_target_links_after_success_and_runs_it(self): + with tempfile.TemporaryDirectory() as directory: + base = Path(directory).resolve() + root = base / "main tree" + scripts = root / "scripts" + scripts.mkdir(parents=True) + (root / "crates").mkdir() + (root / "crates/Cargo.toml").touch() + shared = base / "shared target" + expected = base / "shared target-worktrees" / "main-tree" + for filename in ("qt-run.sh", "qt-target.py"): + shutil.copyfile(Path(__file__).with_name(filename), scripts / filename) + fake_bin = base / "bin" + fake_bin.mkdir() + cargo = fake_bin / "cargo" + cargo.write_text('#!/bin/sh\n[ "$FAIL_METADATA" = 1 ] && exit 7\nprintf \'%s\\n\' "$TARGET_METADATA"\n') + cargo.chmod(0o755) + git = fake_bin / "git" + git.write_text("#!/bin/sh\nexit 128\n") + git.chmod(0o755) + (scripts / "qt-cargo.py").write_text( + 'import os, pathlib, sys\n' + 'target = pathlib.Path(os.environ["CARGO_TARGET_DIR"])\n' + 'assert target == pathlib.Path(os.environ["EXPECTED_TARGET"]), target\n' + 'if os.environ.get("FAIL_BUILD") == "1": sys.exit(9)\n' + '(target / "release").mkdir(parents=True, exist_ok=True)\n' + 'binary = target / "release/qbz"\n' + 'binary.write_text("#!/bin/sh\\nprintf \'artifact executed: %s\\\\n\' \\"$1\\"\\n")\n' + 'binary.chmod(0o755)\n' + ) + metadata = json.dumps({"target_directory": str(shared), "workspace_root": str(root / "crates")}) + env = dict(os.environ, PATH=str(fake_bin) + os.pathsep + os.environ["PATH"], + TARGET_METADATA=metadata, EXPECTED_TARGET=str(expected), NO_AUDIT="1", + NO_TICKER="1", FORCE="1", NORUN="0", SMOKE="0", TEST="0", DEBUG="0", + MOLD="0", XDG_CACHE_HOME=str(base / "cache"), FAIL_METADATA="1", FAIL_BUILD="0") + for name in targets.EXPLICIT_ENV: + env.pop(name, None) + command = ["bash", str(scripts / "qt-run.sh"), "fixture-argument"] + failed = subprocess.run(command, env=env, text=True, capture_output=True) + self.assertNotEqual(failed.returncode, 0) + self.assertFalse(expected.exists()) + env.update(FAIL_METADATA="0", FAIL_BUILD="1") + failed = subprocess.run(command, env=env, text=True, capture_output=True) + self.assertEqual(failed.returncode, 9, failed.stderr) + self.assertFalse((root / "crates/target").is_symlink()) + env["FAIL_BUILD"] = "0" + result = subprocess.run(command, env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("artifact executed: fixture-argument", result.stdout) + self.assertEqual((root / "crates/target/release/qbz").resolve(), expected / "release/qbz") + self.assertFalse((shared / "release/qbz").exists(), "the shared target is never built into") class SdkTests(unittest.TestCase): From 21bbcb126c7d937ea9c1d24828456baf2cfa3ffd Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:14:57 -0600 Subject: [PATCH 11/11] fix(qt): folders holding SACD images show their selection in the rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder rail's tick state counts the selected tracks under each folder by path prefix, `/…`. SACD tracks are selected under their virtual path `sacd:#N`, which never starts with the folder path, so a folder holding ISOs showed an empty or partial tick while its tracks were selected; the bar's count was right. Visible after a Shift range over a folder of SACD images. A folder now also counts `sacd:/…`, the image's tracks below an ancestor, and `sacd:#…`, the tracks of the synthetic folder node whose path is the image itself. Each form is one binary-searched range of the sorted selection, so the per-publish cost stays the same. Tests cover ancestors, the image node, mixed files and images, and a sibling whose name extends the folder's. cc --- crates/qbz-qt/src/local_tree.rs | 76 +++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/qbz-qt/src/local_tree.rs b/crates/qbz-qt/src/local_tree.rs index f824d9737..e0a658fa2 100644 --- a/crates/qbz-qt/src/local_tree.rs +++ b/crates/qbz-qt/src/local_tree.rs @@ -260,6 +260,33 @@ fn sel(f: impl FnOnce(&mut HashMap) -> R) -> R { f(guard.get_or_insert_with(HashMap::new)) } +/// How many selected keys lie under the folder node `folder`. `paths` is the +/// SORTED selection. +/// +/// Ordinary tracks are keyed by their file path, `/…`. SACD tracks are +/// keyed by their virtual path `sacd:#N`: under an ancestor folder that +/// reads `sacd:/…`, and under the image's own synthetic folder node +/// (whose path IS the image) `sacd:#…`. Counting only `/…` left +/// every folder holding ISOs hollow or partial while its tracks were selected. +/// +/// Each form is a byte-order range: every key starting with a prefix sorts +/// between the prefix and the prefix with its last byte bumped by one ('/' +/// 0x2F -> '0', '#' 0x23 -> '$'); the three ranges are disjoint. +fn selected_under(paths: &[String], folder: &str) -> u32 { + let range = |prefix: String| { + let mut upper = prefix.clone().into_bytes(); + if let Some(last) = upper.last_mut() { + *last += 1; + } + let upper = String::from_utf8(upper).unwrap_or_default(); + let start = paths.partition_point(|p| p.as_str() < prefix.as_str()); + let end = paths.partition_point(|p| p.as_str() < upper.as_str()); + end.saturating_sub(start) + }; + (range(format!("{folder}/")) + range(format!("sacd:{folder}/")) + range(format!("sacd:{folder}#"))) + as u32 +} + /// Annotate the visible rows. ONE sorted view of the selected paths is built /// per publish; each folder's "how many of my tracks are selected" is then a /// binary-searched range and each track row a binary search — instead of the @@ -282,14 +309,7 @@ fn annotate(nodes: Vec) -> Vec { .into_iter() .map(|node| { let (selected, select_state) = if node.is_folder { - // Byte-order range over "/…": the upper bound is the - // same prefix with its trailing '/' (0x2F) bumped to '0' - // (0x30), which is the next byte value. - let lo = format!("{}/", node.path); - let hi = format!("{}0", node.path); - let start = paths.partition_point(|p| p.as_str() < lo.as_str()); - let end = paths.partition_point(|p| p.as_str() < hi.as_str()); - let under = (end - start) as u32; + let under = selected_under(&paths, &node.path); let st = if under == 0 { 0 } else if node.track_count > 0 && under >= node.track_count { @@ -489,3 +509,43 @@ pub fn load_folder_detail_blocking(path: &str) -> FolderDetail { tracks: rows, } } + +#[cfg(test)] +mod tests { + use super::selected_under; + + fn sorted(keys: &[&str]) -> Vec { + let mut out: Vec = keys.iter().map(|k| k.to_string()).collect(); + out.sort_unstable(); + out + } + + #[test] + fn sacd_tracks_count_under_their_folders_and_their_image() { + let paths = sorted(&[ + "/m/DSD/A/a-ha/01.dsf", + "sacd:/m/DSD/A/a-ha/Hunting.iso#1", + "sacd:/m/DSD/A/a-ha/Hunting.iso#2", + "sacd:/m/DSD/B/Bowie.iso#1", + ]); + assert_eq!(selected_under(&paths, "/m/DSD/A"), 3); + assert_eq!(selected_under(&paths, "/m/DSD/A/a-ha"), 3); + assert_eq!(selected_under(&paths, "/m/DSD/A/a-ha/Hunting.iso"), 2); + assert_eq!(selected_under(&paths, "/m/DSD"), 4); + assert_eq!(selected_under(&paths, "/m/DSD/B"), 1); + } + + #[test] + fn sibling_prefixes_and_other_images_are_not_counted() { + let paths = sorted(&[ + "/m/ab/01.flac", + "sacd:/m/ab/x.iso#1", + "sacd:/m/a.iso2#1", + "sacd:/m/a.iso#3", + ]); + assert_eq!(selected_under(&paths, "/m/a"), 0); + assert_eq!(selected_under(&paths, "/m/a.iso"), 1); + assert_eq!(selected_under(&paths, "/m/ab"), 2); + assert_eq!(selected_under(&[], "/m"), 0); + } +}