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/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/controls/SelectionModel.qml b/crates/qbz-qt/qml/controls/SelectionModel.qml index 5d6d38460..1563602bc 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,94 @@ 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 + } + + /// 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/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/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/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/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/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/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 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/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 adb303195..952f80d15 100644 --- a/crates/qbz-qt/qml/views/OfflineManagerView.qml +++ b/crates/qbz-qt/qml/views/OfflineManagerView.qml @@ -107,18 +107,34 @@ 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) + } + + // 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 = ({}) + offlineSel.anchorId = "" return } if (id === "select-all") { @@ -507,8 +523,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 +553,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) + } } } 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/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/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/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/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/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..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 { @@ -364,25 +384,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); + }); + } + } } } @@ -460,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); + } +} 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/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/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/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/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/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..3107effa1 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir @@ -9,3 +9,8 @@ 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 +singleton QbzMyQbz 1.0 QbzMyQbz.qml diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml new file mode 100644 index 000000000..1ebd62e35 --- /dev/null +++ b/scripts/qml-tests/tst_multi_select.qml @@ -0,0 +1,455 @@ +// 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" +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 +import "../../crates/qbz-qt/qml/settings" as Settings + +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]) } + } + + // ---- 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" }) + } + + // ---- 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 + } + + // ---- 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 + + 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 = [] + QbzMyQbz.selectCalls = [] + QbzMyQbz.opened = [] + QbzMyQbz.played = [] + QbzLocal.treeCalls = [] + railView.opened = [] + railView.treeSelectMode = true + folderTable.selectedIds = [] + } + + // 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") + } + + 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") + } + + 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") + } + + 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 --------------------------------------------- + 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") + } + + 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") + } + } +} 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 f845cea3b..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 @@ -11,6 +15,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 @@ -22,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 @@ -31,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-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 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):