merge: integrate round 11 (Shift/Ctrl on every select-mode click target, ranges on every multi-select surface, per-worktree build targets, SACD folder ticks) into pre-release

cc
This commit is contained in:
vicrodh committed 2026-09-14 12:15:28 -06:00
commit ae556b3f7d
41 files changed
+1772 -160

No files matched your search

+2
View File
@@ -88,6 +88,7 @@ on:
- "scripts/test-library-folders-qml.sh" - "scripts/test-library-folders-qml.sh"
- "scripts/test-array-model-qml.sh" - "scripts/test-array-model-qml.sh"
- "scripts/test-keyed-model-qml.sh" - "scripts/test-keyed-model-qml.sh"
- "scripts/test-multi-select-qml.sh"
- "scripts/test-wallpaper-field-qml.sh" - "scripts/test-wallpaper-field-qml.sh"
- "scripts/test-card-menu-qml.sh" - "scripts/test-card-menu-qml.sh"
- "scripts/test-orbit-daemon.py" - "scripts/test-orbit-daemon.py"
@@ -120,6 +121,7 @@ on:
- "scripts/test-library-folders-qml.sh" - "scripts/test-library-folders-qml.sh"
- "scripts/test-array-model-qml.sh" - "scripts/test-array-model-qml.sh"
- "scripts/test-keyed-model-qml.sh" - "scripts/test-keyed-model-qml.sh"
- "scripts/test-multi-select-qml.sh"
- "scripts/test-wallpaper-field-qml.sh" - "scripts/test-wallpaper-field-qml.sh"
- "scripts/test-card-menu-qml.sh" - "scripts/test-card-menu-qml.sh"
- "scripts/test-orbit-daemon.py" - "scripts/test-orbit-daemon.py"
+16 -6
View File
@@ -213,7 +213,10 @@ Rectangle {
/// Park the select indicator bottom-right of the art instead of top-right. /// Park the select indicator bottom-right of the art instead of top-right.
property bool selectMarkBottom: false property bool selectMarkBottom: false
property bool selected: 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 // Album blacklist ("Block this album") — LIVE since QbzBlacklist landed
// (`blockAlbum(id, title, artist, coverUrl)`). Kept as a property, not // (`blockAlbum(id, title, artist, coverUrl)`). Kept as a property, not
@@ -561,7 +564,7 @@ Rectangle {
} }
// .slint:169 — in select mode the card click TOGGLES. // .slint:169 — in select mode the card click TOGGLES.
if (root.selectMode) { if (root.selectMode) {
root.selectToggled() root.selectToggled(mouse.modifiers)
return return
} }
if (root.pulledDead) if (root.pulledDead)
@@ -993,7 +996,7 @@ Rectangle {
// .slint:465 — the title carries the SAME target // .slint:465 — the title carries the SAME target
// as the artwork, select mode included. // as the artwork, select mode included.
if (root.selectMode) { if (root.selectMode) {
root.selectToggled() root.selectToggled(mouse.modifiers)
return return
} }
if (root.pulledDead) if (root.pulledDead)
@@ -1009,7 +1012,8 @@ Rectangle {
width: parent.width width: parent.width
height: 18 height: 18
text: root.artist text: root.artist
color: (root.artistId !== "" || root.hostArtistLink) color: !root.selectMode
&& (root.artistId !== "" || root.hostArtistLink)
&& artistArea.containsMouse && artistArea.containsMouse
? theme.textPrimary : theme.textMuted ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLink - 1 font.pixelSize: theme.fontLink - 1
@@ -1019,9 +1023,15 @@ Rectangle {
id: artistArea id: artistArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: root.artistId !== "" || root.hostArtistLink cursorShape: root.selectMode || root.artistId !== "" || root.hostArtistLink
? Qt.PointingHandCursor : Qt.ArrowCursor ? 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 !== "") if (root.artistId !== "")
QbzArtist.openArtist(root.artistId) QbzArtist.openArtist(root.artistId)
else if (root.hostArtistLink) else if (root.hostArtistLink)
+11 -6
View File
@@ -1,6 +1,11 @@
// QbzCheckbox (primitives/QbzCheckbox.slint) — 18px square, r4, accent fill // QbzCheckbox (primitives/QbzCheckbox.slint) — 18px square, r4, accent fill
// with an accent-text check when on, muted border when off. Emits toggled(); // with an accent-text check when on, muted border when off. Emits
// like the Slint original it never self-flips (the owner of the state does). // 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 // Used by Settings > Local Library (folder selection) and the Plex library
// picker, i.e. every place the Slint uses its own QbzCheckbox. // picker, i.e. every place the Slint uses its own QbzCheckbox.
@@ -15,7 +20,7 @@ Rectangle {
id: root id: root
property bool checked: false property bool checked: false
signal toggled() signal toggled(int modifiers)
QbzTheme { id: theme } QbzTheme { id: theme }
@@ -30,14 +35,14 @@ Rectangle {
activeFocusOnTab: root.enabled activeFocusOnTab: root.enabled
Accessible.role: Accessible.CheckBox Accessible.role: Accessible.CheckBox
Accessible.checked: root.checked Accessible.checked: root.checked
Accessible.onToggleAction: if (root.enabled) root.toggled() Accessible.onToggleAction: if (root.enabled) root.toggled(Qt.NoModifier)
Keys.onPressed: function (event) { Keys.onPressed: function (event) {
if (root.enabled && !event.isAutoRepeat if (root.enabled && !event.isAutoRepeat
&& (event.key === Qt.Key_Space && (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return || event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) { || event.key === Qt.Key_Enter)) {
root.toggled() root.toggled(event.modifiers)
event.accepted = true event.accepted = true
} }
} }
@@ -66,6 +71,6 @@ Rectangle {
anchors.fill: parent anchors.fill: parent
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: root.forceActiveFocus() onPressed: root.forceActiveFocus()
onClicked: root.toggled() onClicked: function (mouse) { root.toggled(mouse.modifiers) }
} }
} }
+81 -13
View File
@@ -40,6 +40,20 @@
// //
// A NEW object is always returned: mutating a `var` map in place notifies // A NEW object is always returned: mutating a `var` map in place notifies
// nothing, and every binding on it would go stale. // 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 import QtQuick
@@ -49,40 +63,94 @@ QtObject {
/// The row a shift-range measures from. "" = no anchor yet. Hosts clear /// The row a shift-range measures from. "" = no anchor yet. Hosts clear
/// it when they leave select mode. /// it when they leave select mode.
property string anchorId: "" 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) { function _indexOf(rows, id) {
var wanted = String(id)
for (var i = 0; i < rows.length; i++) for (var i = 0; i < rows.length; i++)
if (rows[i] && rows[i].id === id) if (root._idOf(rows[i]) === wanted)
return i return i
return -1 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 /// The selection map after clicking `id`, given the ordered `rows` the
/// user is looking at and the `modifiers` off the mouse event. /// 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 /// Pass the FILTERED rows when a view is filtered: a range over rows the
/// user cannot see is not a range they asked for. /// user cannot see is not a range they asked for.
function next(current, id, rows, modifiers) { 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 var anchorAt = root.anchorId !== "" ? root._indexOf(rows, root.anchorId) : -1
if (shift && anchorAt >= 0) { if (shift && anchorAt >= 0) {
var here = root._indexOf(rows, id) var here = root._indexOf(rows, id)
if (here >= 0) { 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
// The anchor STAYS, so the same range can be re-dragged. // 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) var out = Object.assign({}, current)
if (out[id] === true) delete out[id] if (out[key] === true) delete out[key]
else out[id] = true else out[key] = true
root.anchorId = id 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 return out
} }
} }
@@ -63,7 +63,7 @@ Rectangle {
QbzMyQbz.kioskDetailArtwork(first, last, px) QbzMyQbz.kioskDetailArtwork(first, last, px)
} }
function openRow(r) { 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) else QbzMyQbz.openItem(r.source, r.itemType, r.sourceItemId)
} }
function entry(label, action) { return {label: root.t(label), action: action} } function entry(label, action) { return {label: root.t(label), action: action} }
+18 -3
View File
@@ -749,7 +749,10 @@ Rectangle {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor 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 { Text {
@@ -969,6 +972,9 @@ Rectangle {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true 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.visible: containsMouse
ToolTip.delay: 300 ToolTip.delay: 300
// NEW msgid — the one string this state needs and // NEW msgid — the one string this state needs and
@@ -1078,7 +1084,12 @@ Rectangle {
MouseArea { MouseArea {
id: artistLinkArea id: artistLinkArea
anchors.fill: parent 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 hoverEnabled: true
cursorShape: (artistLine.linkMode cursorShape: (artistLine.linkMode
? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "") ? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "")
@@ -1088,6 +1099,9 @@ Rectangle {
artistLine.hotSpan = artistLine.spanAt(mouse.x) artistLine.hotSpan = artistLine.spanAt(mouse.x)
} }
onExited: artistLine.hotSpan = -1 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 // Same routing as the menu's "Go to artist" — Slint drives
// both through one handler, so gating only the menu would // both through one handler, so gating only the menu would
// leave the LINK navigating to the track's own artist. // leave the LINK navigating to the track's own artist.
@@ -1116,7 +1130,8 @@ Rectangle {
MouseArea { MouseArea {
id: albumArea id: albumArea
anchors.fill: parent 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 hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
// The names ride along so a release the catalog lost can // The names ride along so a release the catalog lost can
@@ -42,10 +42,23 @@ Column {
}) })
} }
function isSelected(id) { return selectedIds.indexOf(id) >= 0 } function isSelected(id) { return selectedIds.indexOf(id) >= 0 }
function toggleSelected(id) { /// A plain or Ctrl click toggles one folder; Shift adds the range from the
const next = selectedIds.slice() /// last plain click over the rows SHOWN (the filter is a view filter) —
const at = next.indexOf(id) /// the rule every multi-select list shares (controls/SelectionModel.qml).
if (at >= 0) next.splice(at, 1); else next.push(id) 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 selectedIds = next
} }
function scanLabel(ts) { function scanLabel(ts) {
@@ -118,6 +131,7 @@ Column {
QbzBridge.settingsString("library-remove-folders", QbzBridge.settingsString("library-remove-folders",
JSON.stringify(root.selectedIds)) JSON.stringify(root.selectedIds))
root.selectedIds = [] root.selectedIds = []
folderSel.anchorId = ""
} }
} }
} }
@@ -279,7 +293,9 @@ Column {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: root.toggleSelected(folderRow.modelData.id) onClicked: function (mouse) {
root.toggleSelected(folderRow.modelData.id, mouse.modifiers)
}
} }
Row { Row {
@@ -292,7 +308,9 @@ Column {
width: root.kioskHost ? 44 : 20 width: root.kioskHost ? 44 : 20
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: root.isSelected(folderRow.modelData.id) 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. // FOLDER: type glyph + name.
Row { Row {
+5 -4
View File
@@ -244,10 +244,11 @@ Rectangle {
// in-view QML JS (library_bulk.rs:8: "select-all / clear never reach // in-view QML JS (library_bulk.rs:8: "select-all / clear never reach
// Rust"), so the QbzShell signals route to the mounted view's // Rust"), so the QbzShell signals route to the mounted view's
// duck-typed interface: selectAll() / exitMultiSelectMode() / // duck-typed interface: selectAll() / exitMultiSelectMode() /
// multiSelectOn. Implemented by LibraryView, LocalLibraryView and // multiSelectOn. Implemented by every view with a multi-select list:
// LocalAlbumView (MyQbzDetailView is EXIT-ONLY — its selection lives in // Library, Local Library, Local Album, Album, Artist, Playlist, Mix,
// Rust with no select-all arm); views without multi-select (Artist, // Label and the Offline manager. MyQbzDetailView is EXIT-ONLY — its
// Playlist, Mix, Label, Offline) match nothing — PARITY-DEBT (K4). // selection lives in Rust and its bar has no select-all arm (the Slint
// never had one there either).
Connections { Connections {
target: QbzShell target: QbzShell
function onSelectAllRequested() { function onSelectAllRequested() {
+7 -3
View File
@@ -304,8 +304,11 @@ Rectangle {
id: artistArea id: artistArea
anchors.fill: parent anchors.fill: parent
// Only a real artist id is clickable — otherwise the // Only a real artist id is clickable — otherwise the
// pointer promises a page that cannot open. // pointer promises a page that cannot open. In select
enabled: (root.item.artistId || "") !== "" // 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 hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: QbzArtist.openArtist(root.item.artistId) onClicked: QbzArtist.openArtist(root.item.artistId)
@@ -331,7 +334,8 @@ Rectangle {
MouseArea { MouseArea {
id: labelArea id: labelArea
anchors.fill: parent 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 hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.openLabel(root.item.labelId) onClicked: QbzHome.openLabel(root.item.labelId)
+15 -7
View File
@@ -350,15 +350,18 @@ Rectangle {
property string albumSelectKey: "" property string albumSelectKey: ""
property var albumSelected: ({}) property var albumSelected: ({})
readonly property int albumSelectedCount: Object.keys(root.albumSelected).length readonly property int albumSelectedCount: Object.keys(root.albumSelected).length
function albumSelectToggle(id) { /// The armed section's cards follow the same rule as every other
var m = Object.assign({}, root.albumSelected) /// select-mode surface: Shift-click ranges over the section's cards in
if (m[id]) delete m[id] /// their CURRENT sort order, Ctrl adds one (controls/SelectionModel.qml).
else m[id] = true SelectionModel { id: albumSectionSel }
root.albumSelected = m function albumSelectToggle(id, cards, mods) {
root.albumSelected = albumSectionSel.next(root.albumSelected, id, cards || [],
mods === undefined ? Qt.NoModifier : mods)
} }
function albumSelectClear() { function albumSelectClear() {
root.albumSelectKey = "" root.albumSelectKey = ""
root.albumSelected = ({}) root.albumSelected = ({})
albumSectionSel.anchorId = ""
} }
function albumSectionLabel(key) { function albumSectionLabel(key) {
return root.albumSelectKey === key return root.albumSelectKey === key
@@ -393,6 +396,7 @@ Rectangle {
} else if (action === "play-selected") { } else if (action === "play-selected") {
root.albumSelectKey = key root.albumSelectKey = key
root.albumSelected = ({}) root.albumSelected = ({})
albumSectionSel.anchorId = ""
} else if (action === "cancel-selection") { } else if (action === "cancel-selection") {
root.albumSelectClear() root.albumSelectClear()
} }
@@ -2261,7 +2265,9 @@ Rectangle {
selectMode: root.albumSelectKey === (relSection.section.releaseType || "") selectMode: root.albumSelectKey === (relSection.section.releaseType || "")
selected: root.albumSelected[modelData.id] === true selected: root.albumSelected[modelData.id] === true
selectMarkBottom: 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) --------------- // ---- smooth append (owner, 2026-08-02) ---------------
// "que la aparicion de lo que se cargue, sea smooth" — // "que la aparicion de lo que se cargue, sea smooth" —
@@ -3273,7 +3279,9 @@ Rectangle {
selectMode: root.albumSelectKey === "library" selectMode: root.albumSelectKey === "library"
selected: root.albumSelected[modelData.id] === true selected: root.albumSelected[modelData.id] === true
selectMarkBottom: true selectMarkBottom: true
onSelectToggled: root.albumSelectToggle(modelData.id) onSelectToggled: function (mods) {
root.albumSelectToggle(modelData.id, root.libAlbumsSorted, mods)
}
albumId: modelData.id albumId: modelData.id
title: modelData.title title: modelData.title
artist: modelData.artist artist: modelData.artist
+30 -14
View File
@@ -1187,23 +1187,42 @@ Rectangle {
return sortRows(rows, genresSort) 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) { function toggleGenre(key, modifiers) {
selectedGenres = nextFacetSelection(selectedGenres, key, modifiers) selectedGenres = nextFacetSelection(genreFacetSel, selectedGenres, key, modifiers, genreNames)
selectedGenreYears = ({}) selectedGenreYears = ({})
selectedGenreArtists = ({}) selectedGenreArtists = ({})
selectedGenreAlbums = ({}) selectedGenreAlbums = ({})
yearFacetSel.anchorId = ""
artistFacetSel.anchorId = ""
albumFacetSel.anchorId = ""
} }
function toggleGenreYear(key, modifiers) { function toggleGenreYear(key, modifiers) {
selectedGenreYears = nextFacetSelection(selectedGenreYears, key, modifiers) selectedGenreYears = nextFacetSelection(yearFacetSel, selectedGenreYears, key, modifiers,
genreYearOptions)
selectedGenreArtists = ({}) selectedGenreArtists = ({})
selectedGenreAlbums = ({}) selectedGenreAlbums = ({})
artistFacetSel.anchorId = ""
albumFacetSel.anchorId = ""
} }
function toggleGenreArtist(key, modifiers) { function toggleGenreArtist(key, modifiers) {
selectedGenreArtists = nextFacetSelection(selectedGenreArtists, key, modifiers) selectedGenreArtists = nextFacetSelection(artistFacetSel, selectedGenreArtists, key,
modifiers, genreArtistOptions)
selectedGenreAlbums = ({}) selectedGenreAlbums = ({})
albumFacetSel.anchorId = ""
} }
function toggleGenreAlbum(key, modifiers) { function toggleGenreAlbum(key, modifiers) {
selectedGenreAlbums = nextFacetSelection(selectedGenreAlbums, key, modifiers) selectedGenreAlbums = nextFacetSelection(albumFacetSel, selectedGenreAlbums, key, modifiers,
genreAlbumOptions)
} }
function setExplorerColumns(mode) { function setExplorerColumns(mode) {
if (["genre", "year", "both"].indexOf(mode) < 0 || mode === explorerColumns) 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 explorerFacetCount: explorerColumns === "both" ? 4 : 3
readonly property int explorerLeadingCount: explorerColumns === "year" readonly property int explorerLeadingCount: explorerColumns === "year"
? genreYearOptions.length : genreNames.length ? genreYearOptions.length : genreNames.length
function nextFacetSelection(current, key, modifiers) { /// `key` "" is the column's "All" row: it clears the column.
if (key === "") return ({}) function nextFacetSelection(rule, current, key, modifiers, options) {
var additive = (modifiers & Qt.ControlModifier) !== 0 if (key === "") {
|| (modifiers & Qt.MetaModifier) !== 0 rule.anchorId = ""
if (!additive) { return ({})
var only = {}; only[key] = true; return only
} }
var out = Object.assign({}, current) return rule.nextExclusive(current, key, options || [],
if (out[key]) delete out[key] modifiers === undefined ? Qt.NoModifier : modifiers)
else out[key] = true
return out
} }
// The selected artist's albums. // The selected artist's albums.
+4 -1
View File
@@ -124,7 +124,10 @@ Rectangle {
if (action !== "add-to-playlist" && action !== "add-to-mixtape") if (action !== "add-to-playlist" && action !== "add-to-mixtape")
root.selected = ({}) 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() { function selectAll() {
if (!root.multiSelect) root.setMultiSelect(true) if (!root.multiSelect) root.setMultiSelect(true)
root.bulkAction("select-all") root.bulkAction("select-all")
+39 -10
View File
@@ -107,18 +107,34 @@ Rectangle {
var p = root.livePatch[row.trackId] var p = root.livePatch[row.trackId]
return (row.kind === "track" && p !== undefined) ? p.status : row.status return (row.kind === "track" && p !== undefined) ? p.status : row.status
} }
function toggleSelect(trackId) { /// The rule every multi-select list shares (controls/SelectionModel.qml):
var s = Object.assign({}, root.selected) /// a plain or Ctrl click toggles one track, Shift adds the range from the
if (s[trackId] === true) /// last one — over the TRACK rows as drawn, never the album headers
delete s[trackId] /// between them. The Slint had this surface on its Shift-range table
else /// (selection.rs SURFACE_OFFLINE); the port left it toggle-only.
s[trackId] = true SelectionModel { id: offlineSel; idKey: "trackId" }
root.selected = s 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) { function bulkAction(id) {
if (id === "clear") { if (id === "clear") {
root.selected = ({}) root.selected = ({})
offlineSel.anchorId = ""
return return
} }
if (id === "select-all") { if (id === "select-all") {
@@ -507,8 +523,19 @@ Rectangle {
hoverEnabled: true hoverEnabled: true
cursorShape: rowItem.isAlbum cursorShape: rowItem.isAlbum
? Qt.ArrowCursor : Qt.PointingHandCursor ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: if (!rowItem.isAlbum) // A plain click plays; a Shift or Ctrl click is
QbzOffline.playTrack(rowItem.modelData.trackId) // 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 { Row {
@@ -526,7 +553,9 @@ Rectangle {
anchors.centerIn: parent anchors.centerIn: parent
visible: !rowItem.isAlbum visible: !rowItem.isAlbum
checked: rowItem.checked checked: rowItem.checked
onToggled: root.toggleSelect(rowItem.modelData.trackId) onToggled: function (mods) {
root.toggleSelect(rowItem.modelData.trackId, mods)
}
} }
} }
@@ -593,11 +593,14 @@ Item {
selected: root.nativeActive selected: root.nativeActive
? cardCell.slot.selected === true ? cardCell.slot.selected === true
: root.selected[cardCell.slot.id] === 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) if (root.nativeActive)
root.nativeToggleSelect(cardCell.slot.nativeIndex, Qt.NoModifier) root.nativeToggleSelect(cardCell.slot.nativeIndex, mods)
else else
root.toggleSelect(cardCell.slot.id, Qt.NoModifier) root.toggleSelect(cardCell.slot.id, mods)
} }
// Non-select mode only — the card routes a // Non-select mode only — the card routes a
// select-mode click to `selectToggled` and never // select-mode click to `selectToggled` and never
+49 -10
View File
@@ -20,6 +20,52 @@ Item {
QbzTheme { id: theme } 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 { Column {
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: 12 anchors.leftMargin: 12
@@ -149,16 +195,9 @@ Item {
selectMode: root.view.treeSelectMode selectMode: root.view.treeSelectMode
onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded) onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded)
onActivated: root.view.selectFolder(modelData.path) onActivated: root.view.selectFolder(modelData.path)
// NO Shift-range here, deliberately: this rail's selection // Shift ranges from the rail's anchor (see
// lives in RUST (QbzLocal.treeToggle*Select owns the set), // `toggleNodeSelect` at the top of this file).
// not in a QML map, so controls/SelectionModel.qml has onToggleSelect: function (mods) { root.toggleNodeSelect(modelData, mods) }
// 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)
}
} }
} }
QbzScrollBar { QbzScrollBar {
@@ -5,6 +5,10 @@
// Slint declares this inline inside TreeRow; the Qt port also needs it on // 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 // the album cards and the track rows in multi-select, so it is its own file
// (three call sites, one shape). // (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 QtQuick
import com.blitzfc.qbz import com.blitzfc.qbz
@@ -16,7 +20,7 @@ Rectangle {
property bool on: false property bool on: false
property bool partial: false property bool partial: false
property int diameter: 13 property int diameter: 13
signal toggled() signal toggled(int modifiers)
QbzTheme { id: theme } QbzTheme { id: theme }
@@ -52,6 +56,6 @@ Rectangle {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: root.toggled() onClicked: function (mouse) { root.toggled(mouse.modifiers) }
} }
} }
@@ -50,6 +50,15 @@ Rectangle {
if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y) if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return 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() if (root.node.isFolder) root.activated()
} }
} }
@@ -171,7 +171,9 @@ Rectangle {
} }
onClicked: function (mouse) { onClicked: function (mouse) {
if (mouse.button !== Qt.LeftButton) return 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() else root.openThis()
} }
} }
@@ -247,7 +249,10 @@ Rectangle {
visible: root.selectMode visible: root.selectMode
anchors.centerIn: parent anchors.centerIn: parent
on: root.selected 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 { MouseArea {
id: artArea id: artArea
anchors.fill: parent 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 hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbz.playItem(root.sourceItemId) onClicked: QbzMyQbz.playItem(root.sourceItemId)
@@ -321,6 +330,7 @@ Rectangle {
MouseArea { MouseArea {
id: titleArea id: titleArea
anchors.fill: parent anchors.fill: parent
enabled: !root.selectMode
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: root.openThis() onClicked: root.openThis()
@@ -343,6 +353,7 @@ Rectangle {
MouseArea { MouseArea {
id: subArea id: subArea
anchors.fill: parent anchors.fill: parent
enabled: !root.selectMode
hoverEnabled: true hoverEnabled: true
cursorShape: root.subtitleIsLink ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: root.subtitleIsLink ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: { onClicked: {
@@ -1193,7 +1193,10 @@ Rectangle {
// selection checkbox rather than bolt one on. // selection checkbox rather than bolt one on.
selectMode: root.selectMode selectMode: root.selectMode
selected: gcell.cSelected 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 // Card body click, non-select mode only (the card
// routes a select-mode click to `selectToggled`). // routes a select-mode click to `selectToggled`).
onOpenRequested: QbzMyQbz.openItem(gcell.cSource, onOpenRequested: QbzMyQbz.openItem(gcell.cSource,
+9
View File
@@ -365,6 +365,11 @@ pub mod qbz_local {
/// Track checkbox: toggle one row by file path. /// Track checkbox: toggle one row by file path.
#[qinvokable] #[qinvokable]
fn tree_toggle_track_select(self: Pin<&mut QbzLocal>, path: QString); 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. /// Tree-rail bulk bar.
#[qinvokable] #[qinvokable]
fn folders_bulk_action(self: Pin<&mut QbzLocal>, action: QString); 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()); 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) { pub fn folders_bulk_action(self: Pin<&mut Self>, action: QString) {
crate::local_bulk::folders_bulk_action(action.to_string()); crate::local_bulk::folders_bulk_action(action.to_string());
} }
+142 -23
View File
@@ -24,7 +24,9 @@
//! This file is the Qt-facing half of the tree selection; the state and the //! 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. //! 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 cxx_qt_lib::QString;
use qbz_library::LocalTrack; use qbz_library::LocalTrack;
@@ -61,29 +63,124 @@ fn publish_selection() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Rail header toggle. Leaving select mode drops the selection, so the /// 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) { pub fn set_select_mode(on: bool) {
tree::set_tree_select_mode(on); if on {
publish_selection(); 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<VecDeque<TreeSelectOp>> = 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. /// Folder checkbox: recursive, so it is a DB read on a blocking thread.
pub fn toggle_folder_select(path: String) { pub fn toggle_folder_select(path: String) {
crate::spawn(async move { run_tree_select(TreeSelectOp::Folder(path));
let _ =
tokio::task::spawn_blocking(move || tree::toggle_folder_select_blocking(&path)).await;
publish_selection();
});
} }
/// Track checkbox: a deselect is pure state, a select resolves the record /// Track checkbox: a deselect is pure state, a select resolves the record
/// from the parent folder listing — blocking either way. /// from the parent folder listing — blocking either way.
pub fn toggle_track_select(path: String) { pub fn toggle_track_select(path: String) {
crate::spawn(async move { run_tree_select(TreeSelectOp::Track(path));
let _ = }
tokio::task::spawn_blocking(move || tree::toggle_track_select_blocking(&path)).await;
publish_selection(); /// 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::<Vec<Node>>(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) { pub fn folders_bulk_action(action: String) {
match action.as_str() { match action.as_str() {
"select-all" => { "select-all" => {
// Two-way: "check all" un-checks when everything already is. // Two-way: "check all" un-checks when everything already is. In
crate::spawn(async move { // the same queue as the checkboxes, so it sees them all applied.
let _ = tokio::task::spawn_blocking(tree::tree_select_all_blocking).await; run_tree_select(TreeSelectOp::SelectAll);
publish_selection();
});
}
"clear" => {
tree::tree_clear_selection();
publish_selection();
} }
"clear" => run_tree_select(TreeSelectOp::Clear),
_ => { _ => {
let rows = tree::tree_selected_snapshot(); let rows = tree::tree_selected_snapshot();
crate::spawn(async move { crate::spawn(async move {
@@ -336,3 +428,30 @@ async fn enqueue_rows(rows: Vec<LocalTrack>, mode: &str) {
} }
crate::playback_qt::publish_queue(&runtime).await; 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)]);
}
}
+107 -18
View File
@@ -260,6 +260,33 @@ fn sel<R>(f: impl FnOnce(&mut HashMap<String, LocalTrack>) -> R) -> R {
f(guard.get_or_insert_with(HashMap::new)) 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, `<folder>/…`. SACD tracks are
/// keyed by their virtual path `sacd:<image>#N`: under an ancestor folder that
/// reads `sacd:<folder>/…`, and under the image's own synthetic folder node
/// (whose path IS the image) `sacd:<image>#…`. Counting only `<folder>/…` 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 /// 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 /// 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 /// binary-searched range and each track row a binary search — instead of the
@@ -282,14 +309,7 @@ fn annotate(nodes: Vec<TreeNode>) -> Vec<TreeNodeOut> {
.into_iter() .into_iter()
.map(|node| { .map(|node| {
let (selected, select_state) = if node.is_folder { let (selected, select_state) = if node.is_folder {
// Byte-order range over "<path>/…": the upper bound is the let under = selected_under(&paths, &node.path);
// 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 st = if under == 0 { let st = if under == 0 {
0 0
} else if node.track_count > 0 && under >= node.track_count { } 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() { if sel(|s| s.remove(path)).is_some() {
return; return;
} }
// SACD leaves deliberately carry their authoritative virtual if let Some(track) = resolve_track_record(path) {
// `sacd:<image>#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() {
sel(|s| { sel(|s| {
s.insert(path.to_string(), track); s.insert(path.to_string(), track);
}); });
return; }
}
/// The library record behind a track leaf's path.
///
/// SACD leaves deliberately carry their authoritative virtual
/// `sacd:<image>#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<LocalTrack> {
if let Some(track) = with_db(|db| db.get_track_by_path(path)).flatten() {
return Some(track);
} }
let parent = std::path::Path::new(path) let parent = std::path::Path::new(path)
.parent() .parent()
.map(|p| p.to_string_lossy().into_owned()) .map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(); .unwrap_or_default();
let tracks = with_db(|db| db.list_folder_tracks(&parent, false)).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) { tracks.into_iter().find(|t| t.file_path == path)
sel(|s| { }
s.insert(path.to_string(), t);
}); /// 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, tracks: rows,
} }
} }
#[cfg(test)]
mod tests {
use super::selected_under;
fn sorted(keys: &[&str]) -> Vec<String> {
let mut out: Vec<String> = 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);
}
}
+6 -4
View File
@@ -162,9 +162,11 @@ pub mod qbz_myqbz_bridge {
/// Leaving select mode clears the selection. /// Leaving select mode clears the selection.
#[qinvokable] #[qinvokable]
fn detail_toggle_select_mode(self: Pin<&mut QbzMyQbz>); 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] #[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 /// Resets type/source/sort; the search text is left INTACT
/// (myqbz_detail.rs:540-541). /// (myqbz_detail.rs:540-541).
#[qinvokable] #[qinvokable]
@@ -472,8 +474,8 @@ impl qbz_myqbz_bridge::QbzMyQbz {
crate::myqbz_detail_qt::toggle_select_mode(); crate::myqbz_detail_qt::toggle_select_mode();
} }
pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32) { pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32, shift: bool) {
crate::myqbz_detail_qt::toggle_item_select(position); crate::myqbz_detail_qt::toggle_item_select(position, shift);
} }
pub fn detail_reset_filters(self: Pin<&mut Self>) { pub fn detail_reset_filters(self: Pin<&mut Self>) {
+92 -16
View File
@@ -463,6 +463,43 @@ static RESOLVE_CACHE: LazyLock<Mutex<HashMap<String, ResolvedItem>>> =
/// the bulk actions in agreement with what the user sees (spec 02 §12 #13). /// the bulk actions in agreement with what the user sees (spec 02 §12 #13).
static SELECTED: LazyLock<Mutex<HashSet<i32>>> = LazyLock::new(|| Mutex::new(HashSet::new())); static SELECTED: LazyLock<Mutex<HashSet<i32>>> = 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<Option<i32>> = 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<i32>,
anchor: &mut Option<i32>,
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 /// 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. /// whether a rebuild is a RE-DERIVE (clears the selection) or a row patch.
static LAST_FILTER_KEY: Mutex<Option<String>> = Mutex::new(None); static LAST_FILTER_KEY: Mutex<Option<String>> = Mutex::new(None);
@@ -1080,6 +1117,7 @@ fn rebuild(d: &mut DetailDoc) {
// reference, the COUNT below is recomputed from the same set, so the // reference, the COUNT below is recomputed from the same set, so the
// bulk bar can never claim a selection the rows do not show. // bulk bar can never claim a selection the rows do not show.
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
} }
let view = filtered_sorted(d); let view = filtered_sorted(d);
@@ -1431,6 +1469,7 @@ fn reset() {
// `apply` seeds this one back from it. // `apply` seeds this one back from it.
OPEN_ROWS.lock().unwrap().clear(); OPEN_ROWS.lock().unwrap().clear();
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
*LAST_FILTER_KEY.lock().unwrap() = None; *LAST_FILTER_KEY.lock().unwrap() = None;
// Close the persist gate until `apply` restores this collection's prefs. // Close the persist gate until `apply` restores this collection's prefs.
PREFS_HYDRATED.store(false, Ordering::SeqCst); PREFS_HYDRATED.store(false, Ordering::SeqCst);
@@ -2158,6 +2197,7 @@ pub(crate) fn toggle_select_mode() {
let on = !d.select_mode; let on = !d.select_mode;
if !on { if !on {
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
for row in d.items.iter_mut() { for row in d.items.iter_mut() {
row.selected = false; row.selected = false;
} }
@@ -2169,24 +2209,19 @@ pub(crate) fn toggle_select_mode() {
publish(&doc); publish(&doc);
} }
/// Toggle one row's selection by its stable POSITION, then recount. A row patch /// One select-mode click on a row, by its stable POSITION: a toggle, or with
/// — never a re-derive. /// `shift` the range from the anchor (`apply_item_click`). Then every drawn
pub(crate) fn toggle_item_select(position: i32) { /// 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 doc = with_doc(|d| {
let now_selected = { let order: Vec<i32> = d.items.iter().map(|r| r.position).collect();
let mut set = SELECTED.lock().unwrap(); let mut set = SELECTED.lock().unwrap();
if set.contains(&position) { let mut anchor = SELECT_ANCHOR.lock().unwrap();
set.remove(&position); apply_item_click(&order, &mut set, &mut anchor, position, shift);
false for row in d.items.iter_mut() {
} else { row.selected = set.contains(&row.position);
set.insert(position);
true
}
};
if let Some(row) = d.items.iter_mut().find(|r| r.position == position) {
row.selected = now_selected;
} }
d.selected_count = SELECTED.lock().unwrap().len() as i32; d.selected_count = set.len() as i32;
d.clone() d.clone()
}); });
publish(&doc); publish(&doc);
@@ -2197,6 +2232,7 @@ pub(crate) fn toggle_item_select(position: i32) {
pub(crate) fn clear_selection() { pub(crate) fn clear_selection() {
let doc = with_doc(|d| { let doc = with_doc(|d| {
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
for row in d.items.iter_mut() { for row in d.items.iter_mut() {
row.selected = false; row.selected = false;
} }
@@ -2374,6 +2410,7 @@ pub(crate) fn teardown() {
RESOLVE_CACHE.lock().unwrap().clear(); RESOLVE_CACHE.lock().unwrap().clear();
OPEN_ROWS.lock().unwrap().clear(); OPEN_ROWS.lock().unwrap().clear();
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
*LAST_FILTER_KEY.lock().unwrap() = None; *LAST_FILTER_KEY.lock().unwrap() = None;
PREFS_HYDRATED.store(false, Ordering::SeqCst); PREFS_HYDRATED.store(false, Ordering::SeqCst);
let doc = with_doc(|d| { let doc = with_doc(|d| {
@@ -2495,6 +2532,45 @@ pub(crate) fn bulk_action(id: String) {
mod tests { mod tests {
use super::*; use super::*;
fn picked(set: &HashSet<i32>) -> Vec<i32> {
let mut out: Vec<i32> = 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( fn item(
position: i32, position: i32,
title: &str, title: &str,
+4 -1
View File
@@ -89,8 +89,11 @@ case "$arch" in
else jobs="$(nproc)"; fi else jobs="$(nproc)"; fi
say "RAM ${mem_mb} MB -> CARGO_BUILD_JOBS=${jobs}" say "RAM ${mem_mb} MB -> CARGO_BUILD_JOBS=${jobs}"
# No RUSTFLAGS, no mold, stable: same cache rule as qt-run.sh. # 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 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) x86_64 | amd64)
die "CROSS mode is not supported for the Qt binary (see the header: cxx-qt \ die "CROSS mode is not supported for the Qt binary (see the header: cxx-qt \
+10 -2
View File
@@ -48,8 +48,12 @@ case "$arch" in
else else
echo "[qbzd-aarch64] non-apt distro: install the equivalents of: ${DEPS[*]}" >&2 echo "[qbzd-aarch64] non-apt distro: install the equivalents of: ${DEPS[*]}" >&2
fi 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 ) ( 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) x86_64 | amd64)
echo "[qbzd-aarch64] CROSS-compile from $arch via cross (Docker)" 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}" 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 # 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). # 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 ) ( 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 echo "[qbzd-aarch64] ERROR: unsupported build host arch: $arch" >&2
+1 -1
View File
@@ -31,7 +31,7 @@ fail() { echo "FAIL: $1" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || fail "curl is required" 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 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" 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:: # Isolated scratch profile root. NEVER the real daemon/desktop roots: dirs::
@@ -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)]) }
}
@@ -0,0 +1,6 @@
pragma Singleton
import QtQuick
QtObject {
property var opened: []
function openArtist(id) { opened = opened.concat([String(id)]) }
}
@@ -0,0 +1,6 @@
pragma Singleton
import QtQuick
QtObject {
property var labels: []
function openLabel(id) { labels = labels.concat([String(id)]) }
}
@@ -6,4 +6,18 @@ QtObject {
property int pairingCancels: 0 property int pairingCancels: 0
function mediaCancelQuickConnect() { pairingCancels++ } function mediaCancelQuickConnect() { pairingCancels++ }
function mediaQuickConnect(url) {} 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) {}
} }
@@ -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) {}
}
@@ -0,0 +1,6 @@
pragma Singleton
import QtQuick
QtObject {
property var submitted: []
function searchSubmit(text) { submitted = submitted.concat([String(text)]) }
}
@@ -9,3 +9,8 @@ singleton QbzPurchases 1.0 QbzPurchases.qml
singleton QbzOrbit 1.0 QbzOrbit.qml singleton QbzOrbit 1.0 QbzOrbit.qml
singleton QbzAbout 1.0 QbzAbout.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
+455
View File
@@ -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")
}
}
}
+50
View File
@@ -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 No target cleanup, profile changes, or RUSTFLAGS: a content-derived, unused
C++ define invalidates cc-rs consumers when the SDK changes, in either profile C++ define invalidates cc-rs consumers when the SDK changes, in either profile
and with any Rust toolchain. Existing CXXFLAGS (including MSVC flags) survive. 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 hashlib
import importlib.util
import os import os
from pathlib import Path from pathlib import Path
import re import re
@@ -112,12 +118,56 @@ def cargo_environment(env):
return result, version, fingerprint 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(): def main():
if len(sys.argv) < 2: if len(sys.argv) < 2:
raise RuntimeError("usage: qt-cargo.py <cargo subcommand> [arguments...]") raise RuntimeError("usage: qt-cargo.py <cargo subcommand> [arguments...]")
env, version, fingerprint = cargo_environment(os.environ) env, version, fingerprint = cargo_environment(os.environ)
print(f"[qt-cargo] Qt {version}, SDK {fingerprint}, qmake={env['QMAKE']}", print(f"[qt-cargo] Qt {version}, SDK {fingerprint}, qmake={env['QMAKE']}",
file=sys.stderr, flush=True) file=sys.stderr, flush=True)
env = target_environment(env, sys.argv[2:])
# subprocess.run, NOT os.execvpe: on Windows os.exec* is emulated (spawn + # subprocess.run, NOT os.execvpe: on Windows os.exec* is emulated (spawn +
# terminate) and segfaulted here — the release-windows build died with # terminate) and segfaulted here — the release-windows build died with
# "Segmentation fault ... exit code 139" right after this line on # "Segmentation fault ... exit code 139" right after this line on
+35 -2
View File
@@ -33,6 +33,16 @@
# run BEFORE the build — failing in one second beats failing after two minutes, # 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. # 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, `<target>-worktrees/<worktree>`, 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] # Usage: ./scripts/qt-run.sh [extra app args]
# DEBUG=1 ./scripts/qt-run.sh # debug profile (the binary the gate uses) # DEBUG=1 ./scripts/qt-run.sh # debug profile (the binary the gate uses)
# NORUN=1 ./scripts/qt-run.sh # build only, don't exec # NORUN=1 ./scripts/qt-run.sh # build only, don't exec
@@ -127,7 +137,6 @@ if [[ "${DEBUG:-0}" == 1 ]]; then
else else
PROFILE=release; PROFILE_ARGS=(--release) PROFILE=release; PROFILE_ARGS=(--release)
fi fi
BIN="${CARGO_TARGET_DIR:-crates/target}/${PROFILE}/qbz"
# --- ONE BUILD AT A TIME, BOX-WIDE ------------------------------------------ # --- ONE BUILD AT A TIME, BOX-WIDE ------------------------------------------
# Not paranoia: a Slint `qbz-ui` rustc in another worktree peaks 20-30 GB on a # 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
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) ------------------------ # --- The QML audits (fail fast; cargo cannot see QML) ------------------------
if [[ "${NO_AUDIT:-0}" != 1 ]]; then if [[ "${NO_AUDIT:-0}" != 1 ]]; then
if [[ -d "${AUDIT_DIR}" ]] && command -v python3 >/dev/null 2>&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) avail_mb=$(mem_avail_mb)
say "profile=${PROFILE} jobs=${CARGO_BUILD_JOBS} avail=${avail_mb}MB rustflags='${RUSTFLAGS:-}'" 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 ------------------------------------------------------------ # --- Start banner ------------------------------------------------------------
build_start=$(date +%s) build_start=$(date +%s)
@@ -232,6 +259,11 @@ fi
# --- The build --------------------------------------------------------------- # --- The build ---------------------------------------------------------------
python3 scripts/qt-cargo.py build "${PROFILE_ARGS[@]}" --manifest-path crates/Cargo.toml -p qbz-qt 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 ------------ # --- 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; } [[ -n "${tick_pid}" ]] && { kill "${tick_pid}" 2>/dev/null || true; wait "${tick_pid}" 2>/dev/null || true; }
trap - EXIT 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 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' \ 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}" "${C_BOLD}${C_GRN}" "$(date '+%H:%M:%S')" "$(fmt_dur "${build_secs}")" "${PROFILE}" "${C_RST}"
say "binary=${BIN}"
# --- Tests (opt-in) ---------------------------------------------------------- # --- Tests (opt-in) ----------------------------------------------------------
if [[ "${TEST:-0}" == 1 ]]; then if [[ "${TEST:-0}" == 1 ]]; then
@@ -260,7 +293,7 @@ if [[ "${SMOKE:-0}" == 1 ]]; then
# template path so the two agree. # template path so the two agree.
log="$(mktemp "${TMPDIR:-/tmp}/qbz-qt-smoke-XXXXXX")" log="$(mktemp "${TMPDIR:-/tmp}/qbz-qt-smoke-XXXXXX")"
say "offscreen smoke (75s max) → ${log}" 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 # 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 # `timeout`-not-found case did exactly that). Zero complaints in an empty log
# is not a pass. # is not a pass.
+8 -1
View File
@@ -3,6 +3,10 @@
set -euo pipefail set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
export QBZ_PREBUILT_SHADERS=1 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-search-local-qml.sh
bash scripts/test-updates-qml.sh bash scripts/test-updates-qml.sh
bash scripts/test-playback-cache-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-library-folders-qml.sh
bash scripts/test-array-model-qml.sh bash scripts/test-array-model-qml.sh
bash scripts/test-keyed-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-wallpaper-field-qml.sh
bash scripts/test-card-menu-qml.sh bash scripts/test-card-menu-qml.sh
bash scripts/test-orbit-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_kiosk_feedback.mjs
node scripts/test_qt_exclusive_gate.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 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")" logs="$(mktemp -d "${TMPDIR:-/tmp}/qbz-qt-gate-XXXXXX")"
printf '[qt-gate] startup logs: %s\n' "$logs" printf '[qt-gate] startup logs: %s\n' "$logs"
for profile in debug release; do 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-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" python3 scripts/qt-smoke.py "$target_dir/$profile/qbz" --log "$logs/$profile.log"
done 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. # 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" python3 scripts/qt-smoke.py "$target_dir/release/qbz" --silent-bus --log "$logs/release-silent-bus.log"
+213
View File
@@ -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:
<configured target>-worktrees/<worktree directory name>
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)
+21
View File
@@ -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
+221
View File
@@ -1,9 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SDK-cache and crash-aware smoke regressions; no Qt or Cargo build needed.""" """SDK-cache and crash-aware smoke regressions; no Qt or Cargo build needed."""
import contextlib
import importlib.util import importlib.util
import io
import json
import os import os
from pathlib import Path from pathlib import Path
import shutil
import subprocess
import tempfile import tempfile
import unittest import unittest
import sys import sys
@@ -21,6 +26,222 @@ def load(name, filename):
qt = load("qt_cargo", "qt-cargo.py") qt = load("qt_cargo", "qt-cargo.py")
smoke = load("qt_smoke", "qt-smoke.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): class SdkTests(unittest.TestCase):