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:
41 files changed
+1772
-160
No files matched your search
@@ -88,6 +88,7 @@ on:
|
||||
- "scripts/test-library-folders-qml.sh"
|
||||
- "scripts/test-array-model-qml.sh"
|
||||
- "scripts/test-keyed-model-qml.sh"
|
||||
- "scripts/test-multi-select-qml.sh"
|
||||
- "scripts/test-wallpaper-field-qml.sh"
|
||||
- "scripts/test-card-menu-qml.sh"
|
||||
- "scripts/test-orbit-daemon.py"
|
||||
@@ -120,6 +121,7 @@ on:
|
||||
- "scripts/test-library-folders-qml.sh"
|
||||
- "scripts/test-array-model-qml.sh"
|
||||
- "scripts/test-keyed-model-qml.sh"
|
||||
- "scripts/test-multi-select-qml.sh"
|
||||
- "scripts/test-wallpaper-field-qml.sh"
|
||||
- "scripts/test-card-menu-qml.sh"
|
||||
- "scripts/test-orbit-daemon.py"
|
||||
|
||||
@@ -213,7 +213,10 @@ Rectangle {
|
||||
/// Park the select indicator bottom-right of the art instead of top-right.
|
||||
property bool selectMarkBottom: false
|
||||
property bool selected: false
|
||||
signal selectToggled()
|
||||
/// `modifiers` rides straight off the click: a grid host turns Shift into
|
||||
/// a range (controls/SelectionModel.qml). Every select-mode target on the
|
||||
/// card — artwork, title, artist line — emits it.
|
||||
signal selectToggled(int modifiers)
|
||||
|
||||
// Album blacklist ("Block this album") — LIVE since QbzBlacklist landed
|
||||
// (`blockAlbum(id, title, artist, coverUrl)`). Kept as a property, not
|
||||
@@ -561,7 +564,7 @@ Rectangle {
|
||||
}
|
||||
// .slint:169 — in select mode the card click TOGGLES.
|
||||
if (root.selectMode) {
|
||||
root.selectToggled()
|
||||
root.selectToggled(mouse.modifiers)
|
||||
return
|
||||
}
|
||||
if (root.pulledDead)
|
||||
@@ -993,7 +996,7 @@ Rectangle {
|
||||
// .slint:465 — the title carries the SAME target
|
||||
// as the artwork, select mode included.
|
||||
if (root.selectMode) {
|
||||
root.selectToggled()
|
||||
root.selectToggled(mouse.modifiers)
|
||||
return
|
||||
}
|
||||
if (root.pulledDead)
|
||||
@@ -1009,7 +1012,8 @@ Rectangle {
|
||||
width: parent.width
|
||||
height: 18
|
||||
text: root.artist
|
||||
color: (root.artistId !== "" || root.hostArtistLink)
|
||||
color: !root.selectMode
|
||||
&& (root.artistId !== "" || root.hostArtistLink)
|
||||
&& artistArea.containsMouse
|
||||
? theme.textPrimary : theme.textMuted
|
||||
font.pixelSize: theme.fontLink - 1
|
||||
@@ -1019,9 +1023,15 @@ Rectangle {
|
||||
id: artistArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.artistId !== "" || root.hostArtistLink
|
||||
cursorShape: root.selectMode || root.artistId !== "" || root.hostArtistLink
|
||||
? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
onClicked: function (mouse) {
|
||||
// In select mode the artist line is the card too:
|
||||
// opening the artist mid-selection would drop it.
|
||||
if (root.selectMode) {
|
||||
root.selectToggled(mouse.modifiers)
|
||||
return
|
||||
}
|
||||
if (root.artistId !== "")
|
||||
QbzArtist.openArtist(root.artistId)
|
||||
else if (root.hostArtistLink)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// QbzCheckbox (primitives/QbzCheckbox.slint) — 18px square, r4, accent fill
|
||||
// with an accent-text check when on, muted border when off. Emits toggled();
|
||||
// like the Slint original it never self-flips (the owner of the state does).
|
||||
// with an accent-text check when on, muted border when off. Emits
|
||||
// toggled(modifiers); like the Slint original it never self-flips (the owner of
|
||||
// the state does).
|
||||
//
|
||||
// `modifiers` is the click's (or the key's), straight through: a multi-select
|
||||
// host turns Shift into a range (controls/SelectionModel.qml). Hosts that only
|
||||
// flip a flag keep a plain expression handler and never look at it.
|
||||
//
|
||||
// Used by Settings > Local Library (folder selection) and the Plex library
|
||||
// picker, i.e. every place the Slint uses its own QbzCheckbox.
|
||||
@@ -15,7 +20,7 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property bool checked: false
|
||||
signal toggled()
|
||||
signal toggled(int modifiers)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -30,14 +35,14 @@ Rectangle {
|
||||
activeFocusOnTab: root.enabled
|
||||
Accessible.role: Accessible.CheckBox
|
||||
Accessible.checked: root.checked
|
||||
Accessible.onToggleAction: if (root.enabled) root.toggled()
|
||||
Accessible.onToggleAction: if (root.enabled) root.toggled(Qt.NoModifier)
|
||||
|
||||
Keys.onPressed: function (event) {
|
||||
if (root.enabled && !event.isAutoRepeat
|
||||
&& (event.key === Qt.Key_Space
|
||||
|| event.key === Qt.Key_Return
|
||||
|| event.key === Qt.Key_Enter)) {
|
||||
root.toggled()
|
||||
root.toggled(event.modifiers)
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
@@ -66,6 +71,6 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: root.forceActiveFocus()
|
||||
onClicked: root.toggled()
|
||||
onClicked: function (mouse) { root.toggled(mouse.modifiers) }
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,20 @@
|
||||
//
|
||||
// A NEW object is always returned: mutating a `var` map in place notifies
|
||||
// nothing, and every binding on it would go stale.
|
||||
//
|
||||
// ── EVERY CLICK TARGET MUST HAND ITS MODIFIERS OVER ───────────────────────
|
||||
// The rule only sees what the click target passes in. A select-mode surface
|
||||
// has more targets than its row body — the checkbox disc, the checkbox
|
||||
// controls, a whole album card, the name links drawn on a row — and each one
|
||||
// that called its host with no modifiers turned every Shift-click there into
|
||||
// a plain toggle. `scripts/qml-tests/tst_multi_select.qml` clicks all of them
|
||||
// through the real components.
|
||||
//
|
||||
// ── IDS COMPARE AS STRINGS ────────────────────────────────────────────────
|
||||
// `anchorId` is a string property, so a numeric id (an offline track id, a
|
||||
// settings folder id) was stored as "42" and then never found again among
|
||||
// rows carrying 42 — Shift silently degraded to a toggle. The selection map
|
||||
// keys are strings anyway, so comparing as strings is the same identity.
|
||||
|
||||
import QtQuick
|
||||
|
||||
@@ -49,40 +63,94 @@ QtObject {
|
||||
/// The row a shift-range measures from. "" = no anchor yet. Hosts clear
|
||||
/// it when they leave select mode.
|
||||
property string anchorId: ""
|
||||
/// The row field that identifies a row. Track and album rows use `id`;
|
||||
/// the offline manager's rows carry `trackId`, the explorer facets `key`.
|
||||
property string idKey: "id"
|
||||
|
||||
function _idOf(row) {
|
||||
if (!row)
|
||||
return ""
|
||||
var value = row[root.idKey]
|
||||
return value === undefined || value === null ? "" : String(value)
|
||||
}
|
||||
|
||||
function _indexOf(rows, id) {
|
||||
var wanted = String(id)
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
if (rows[i] && rows[i].id === id)
|
||||
if (root._idOf(rows[i]) === wanted)
|
||||
return i
|
||||
return -1
|
||||
}
|
||||
|
||||
/// Adds every identified row between the anchor and `id` to `into`.
|
||||
/// Rows without an id (group headers riding in the same list) are skipped.
|
||||
function _fillRange(into, rows, anchorAt, here) {
|
||||
var lo = Math.min(anchorAt, here)
|
||||
var hi = Math.max(anchorAt, here)
|
||||
for (var i = lo; i <= hi; i++) {
|
||||
var rowId = root._idOf(rows[i])
|
||||
if (rowId !== "")
|
||||
into[rowId] = true
|
||||
}
|
||||
return into
|
||||
}
|
||||
|
||||
/// The selection map after clicking `id`, given the ordered `rows` the
|
||||
/// user is looking at and the `modifiers` off the mouse event.
|
||||
///
|
||||
/// Pass the FILTERED rows when a view is filtered: a range over rows the
|
||||
/// user cannot see is not a range they asked for.
|
||||
function next(current, id, rows, modifiers) {
|
||||
var shift = (modifiers & Qt.ShiftModifier) !== 0
|
||||
var shift = ((modifiers || 0) & Qt.ShiftModifier) !== 0
|
||||
var anchorAt = root.anchorId !== "" ? root._indexOf(rows, root.anchorId) : -1
|
||||
|
||||
if (shift && anchorAt >= 0) {
|
||||
var here = root._indexOf(rows, id)
|
||||
if (here >= 0) {
|
||||
var lo = Math.min(anchorAt, here)
|
||||
var hi = Math.max(anchorAt, here)
|
||||
var m = Object.assign({}, current)
|
||||
for (var i = lo; i <= hi; i++)
|
||||
m[rows[i].id] = true
|
||||
if (here >= 0)
|
||||
// The anchor STAYS, so the same range can be re-dragged.
|
||||
return m
|
||||
}
|
||||
return root._fillRange(Object.assign({}, current), rows, anchorAt, here)
|
||||
}
|
||||
|
||||
var key = String(id)
|
||||
var out = Object.assign({}, current)
|
||||
if (out[id] === true) delete out[id]
|
||||
else out[id] = true
|
||||
root.anchorId = id
|
||||
if (out[key] === true) delete out[key]
|
||||
else out[key] = true
|
||||
root.anchorId = key
|
||||
return out
|
||||
}
|
||||
|
||||
/// The spreadsheet / file-manager rule, for lists where a plain click
|
||||
/// REPLACES the selection (the Library Explorer facets):
|
||||
///
|
||||
/// plain click only this row; it becomes the anchor
|
||||
/// Ctrl / Cmd toggle this row, keep the rest; it becomes the anchor
|
||||
/// Shift only the range anchor..row; the anchor stays
|
||||
/// Ctrl+Shift add the range anchor..row to the selection
|
||||
///
|
||||
/// Without a visible anchor Shift behaves like the same click without it.
|
||||
function nextExclusive(current, id, rows, modifiers) {
|
||||
var mods = modifiers || 0
|
||||
var shift = (mods & Qt.ShiftModifier) !== 0
|
||||
var additive = (mods & (Qt.ControlModifier | Qt.MetaModifier)) !== 0
|
||||
var key = String(id)
|
||||
var anchorAt = root.anchorId !== "" ? root._indexOf(rows, root.anchorId) : -1
|
||||
|
||||
if (shift && anchorAt >= 0) {
|
||||
var here = root._indexOf(rows, id)
|
||||
if (here >= 0)
|
||||
return root._fillRange(additive ? Object.assign({}, current) : {},
|
||||
rows, anchorAt, here)
|
||||
}
|
||||
|
||||
root.anchorId = key
|
||||
if (!additive) {
|
||||
var only = {}
|
||||
only[key] = true
|
||||
return only
|
||||
}
|
||||
var out = Object.assign({}, current)
|
||||
if (out[key] === true) delete out[key]
|
||||
else out[key] = true
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ Rectangle {
|
||||
QbzMyQbz.kioskDetailArtwork(first, last, px)
|
||||
}
|
||||
function openRow(r) {
|
||||
if (root.doc.selectMode) QbzMyQbz.detailToggleItemSelect(r.position)
|
||||
if (root.doc.selectMode) QbzMyQbz.detailToggleItemSelect(r.position, false)
|
||||
else QbzMyQbz.openItem(r.source, r.itemType, r.sourceItemId)
|
||||
}
|
||||
function entry(label, action) { return {label: root.t(label), action: action} }
|
||||
|
||||
@@ -749,7 +749,10 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleSelect(Qt.NoModifier)
|
||||
// The disc is where a select-mode click most often lands,
|
||||
// so it hands over the modifiers exactly like the body:
|
||||
// a plain `NoModifier` here made Shift-click a toggle.
|
||||
onClicked: function (mouse) { root.toggleSelect(mouse.modifiers) }
|
||||
}
|
||||
}
|
||||
Text {
|
||||
@@ -969,6 +972,9 @@ Rectangle {
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
// Hover only: a tooltip must not eat the click the
|
||||
// row body (play, or a select-mode toggle) owns.
|
||||
acceptedButtons: Qt.NoButton
|
||||
ToolTip.visible: containsMouse
|
||||
ToolTip.delay: 300
|
||||
// NEW msgid — the one string this state needs and
|
||||
@@ -1078,7 +1084,12 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: artistLinkArea
|
||||
anchors.fill: parent
|
||||
enabled: root.artistLink && (artistLine.linkMode || !!root.item.artistId)
|
||||
// In select mode the whole row is a selection target
|
||||
// (TrackRow.slint:174): the names stop being links so the
|
||||
// click — and its Shift / Ctrl — reaches the row body
|
||||
// instead of opening an artist page mid-selection.
|
||||
enabled: !root.selectMode
|
||||
&& root.artistLink && (artistLine.linkMode || !!root.item.artistId)
|
||||
hoverEnabled: true
|
||||
cursorShape: (artistLine.linkMode
|
||||
? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "")
|
||||
@@ -1088,6 +1099,9 @@ Rectangle {
|
||||
artistLine.hotSpan = artistLine.spanAt(mouse.x)
|
||||
}
|
||||
onExited: artistLine.hotSpan = -1
|
||||
// Entering select mode under the pointer disables the area
|
||||
// without an `exited`; the hot name must not stay lit.
|
||||
onEnabledChanged: if (!enabled) artistLine.hotSpan = -1
|
||||
// Same routing as the menu's "Go to artist" — Slint drives
|
||||
// both through one handler, so gating only the menu would
|
||||
// leave the LINK navigating to the track's own artist.
|
||||
@@ -1116,7 +1130,8 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: albumArea
|
||||
anchors.fill: parent
|
||||
enabled: !!root.item.albumId && !root.releaseGone
|
||||
// Not a link in select mode, for the artist line's reason.
|
||||
enabled: !root.selectMode && !!root.item.albumId && !root.releaseGone
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// The names ride along so a release the catalog lost can
|
||||
|
||||
@@ -42,10 +42,23 @@ Column {
|
||||
})
|
||||
}
|
||||
function isSelected(id) { return selectedIds.indexOf(id) >= 0 }
|
||||
function toggleSelected(id) {
|
||||
const next = selectedIds.slice()
|
||||
const at = next.indexOf(id)
|
||||
if (at >= 0) next.splice(at, 1); else next.push(id)
|
||||
/// A plain or Ctrl click toggles one folder; Shift adds the range from the
|
||||
/// last plain click over the rows SHOWN (the filter is a view filter) —
|
||||
/// the rule every multi-select list shares (controls/SelectionModel.qml).
|
||||
SelectionModel { id: folderSel }
|
||||
function toggleSelected(id, mods) {
|
||||
const rows = root.shown()
|
||||
const current = {}
|
||||
for (let i = 0; i < selectedIds.length; i++)
|
||||
current[String(selectedIds[i])] = true
|
||||
const picked = folderSel.next(current, id, rows,
|
||||
mods === undefined ? Qt.NoModifier : mods)
|
||||
// Back to the ids themselves (the removal action takes them as
|
||||
// numbers), keeping earlier picks the filter currently hides.
|
||||
const next = selectedIds.filter(function (sid) { return picked[String(sid)] === true })
|
||||
for (let j = 0; j < rows.length; j++)
|
||||
if (picked[String(rows[j].id)] === true && next.indexOf(rows[j].id) < 0)
|
||||
next.push(rows[j].id)
|
||||
selectedIds = next
|
||||
}
|
||||
function scanLabel(ts) {
|
||||
@@ -118,6 +131,7 @@ Column {
|
||||
QbzBridge.settingsString("library-remove-folders",
|
||||
JSON.stringify(root.selectedIds))
|
||||
root.selectedIds = []
|
||||
folderSel.anchorId = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +293,9 @@ Column {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleSelected(folderRow.modelData.id)
|
||||
onClicked: function (mouse) {
|
||||
root.toggleSelected(folderRow.modelData.id, mouse.modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
@@ -292,7 +308,9 @@ Column {
|
||||
width: root.kioskHost ? 44 : 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.isSelected(folderRow.modelData.id)
|
||||
onToggled: root.toggleSelected(folderRow.modelData.id)
|
||||
onToggled: function (mods) {
|
||||
root.toggleSelected(folderRow.modelData.id, mods)
|
||||
}
|
||||
}
|
||||
// FOLDER: type glyph + name.
|
||||
Row {
|
||||
|
||||
@@ -244,10 +244,11 @@ Rectangle {
|
||||
// in-view QML JS (library_bulk.rs:8: "select-all / clear never reach
|
||||
// Rust"), so the QbzShell signals route to the mounted view's
|
||||
// duck-typed interface: selectAll() / exitMultiSelectMode() /
|
||||
// multiSelectOn. Implemented by LibraryView, LocalLibraryView and
|
||||
// LocalAlbumView (MyQbzDetailView is EXIT-ONLY — its selection lives in
|
||||
// Rust with no select-all arm); views without multi-select (Artist,
|
||||
// Playlist, Mix, Label, Offline) match nothing — PARITY-DEBT (K4).
|
||||
// multiSelectOn. Implemented by every view with a multi-select list:
|
||||
// Library, Local Library, Local Album, Album, Artist, Playlist, Mix,
|
||||
// Label and the Offline manager. MyQbzDetailView is EXIT-ONLY — its
|
||||
// selection lives in Rust and its bar has no select-all arm (the Slint
|
||||
// never had one there either).
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onSelectAllRequested() {
|
||||
|
||||
@@ -304,8 +304,11 @@ Rectangle {
|
||||
id: artistArea
|
||||
anchors.fill: parent
|
||||
// Only a real artist id is clickable — otherwise the
|
||||
// pointer promises a page that cannot open.
|
||||
enabled: (root.item.artistId || "") !== ""
|
||||
// pointer promises a page that cannot open. In select
|
||||
// mode the whole row selects (Shift / Ctrl included), so
|
||||
// the name stops being a link and the click falls to the
|
||||
// row body underneath.
|
||||
enabled: !root.selectMode && (root.item.artistId || "") !== ""
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzArtist.openArtist(root.item.artistId)
|
||||
@@ -331,7 +334,8 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: labelArea
|
||||
anchors.fill: parent
|
||||
enabled: (root.item.labelId || "") !== ""
|
||||
// Not a link in select mode, for the artist name's reason.
|
||||
enabled: !root.selectMode && (root.item.labelId || "") !== ""
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzHome.openLabel(root.item.labelId)
|
||||
|
||||
@@ -350,15 +350,18 @@ Rectangle {
|
||||
property string albumSelectKey: ""
|
||||
property var albumSelected: ({})
|
||||
readonly property int albumSelectedCount: Object.keys(root.albumSelected).length
|
||||
function albumSelectToggle(id) {
|
||||
var m = Object.assign({}, root.albumSelected)
|
||||
if (m[id]) delete m[id]
|
||||
else m[id] = true
|
||||
root.albumSelected = m
|
||||
/// The armed section's cards follow the same rule as every other
|
||||
/// select-mode surface: Shift-click ranges over the section's cards in
|
||||
/// their CURRENT sort order, Ctrl adds one (controls/SelectionModel.qml).
|
||||
SelectionModel { id: albumSectionSel }
|
||||
function albumSelectToggle(id, cards, mods) {
|
||||
root.albumSelected = albumSectionSel.next(root.albumSelected, id, cards || [],
|
||||
mods === undefined ? Qt.NoModifier : mods)
|
||||
}
|
||||
function albumSelectClear() {
|
||||
root.albumSelectKey = ""
|
||||
root.albumSelected = ({})
|
||||
albumSectionSel.anchorId = ""
|
||||
}
|
||||
function albumSectionLabel(key) {
|
||||
return root.albumSelectKey === key
|
||||
@@ -393,6 +396,7 @@ Rectangle {
|
||||
} else if (action === "play-selected") {
|
||||
root.albumSelectKey = key
|
||||
root.albumSelected = ({})
|
||||
albumSectionSel.anchorId = ""
|
||||
} else if (action === "cancel-selection") {
|
||||
root.albumSelectClear()
|
||||
}
|
||||
@@ -2261,7 +2265,9 @@ Rectangle {
|
||||
selectMode: root.albumSelectKey === (relSection.section.releaseType || "")
|
||||
selected: root.albumSelected[modelData.id] === true
|
||||
selectMarkBottom: true
|
||||
onSelectToggled: root.albumSelectToggle(modelData.id)
|
||||
onSelectToggled: function (mods) {
|
||||
root.albumSelectToggle(modelData.id, relSection.section.cards || [], mods)
|
||||
}
|
||||
|
||||
// ---- smooth append (owner, 2026-08-02) ---------------
|
||||
// "que la aparicion de lo que se cargue, sea smooth" —
|
||||
@@ -3273,7 +3279,9 @@ Rectangle {
|
||||
selectMode: root.albumSelectKey === "library"
|
||||
selected: root.albumSelected[modelData.id] === true
|
||||
selectMarkBottom: true
|
||||
onSelectToggled: root.albumSelectToggle(modelData.id)
|
||||
onSelectToggled: function (mods) {
|
||||
root.albumSelectToggle(modelData.id, root.libAlbumsSorted, mods)
|
||||
}
|
||||
albumId: modelData.id
|
||||
title: modelData.title
|
||||
artist: modelData.artist
|
||||
|
||||
@@ -1187,23 +1187,42 @@ Rectangle {
|
||||
return sortRows(rows, genresSort)
|
||||
}
|
||||
|
||||
// The Explorer facets follow the spreadsheet rule — a plain click picks
|
||||
// one value, Ctrl adds or removes one, Shift picks the run from the last
|
||||
// click, Ctrl+Shift adds that run (SelectionModel.nextExclusive) — each
|
||||
// column over its values AS LISTED (sorted, and filtered by its own
|
||||
// search box). One anchor per column; a column whose values are rebuilt
|
||||
// by a change upstream starts without one.
|
||||
SelectionModel { id: genreFacetSel; idKey: "key" }
|
||||
SelectionModel { id: yearFacetSel; idKey: "key" }
|
||||
SelectionModel { id: artistFacetSel; idKey: "key" }
|
||||
SelectionModel { id: albumFacetSel; idKey: "key" }
|
||||
function toggleGenre(key, modifiers) {
|
||||
selectedGenres = nextFacetSelection(selectedGenres, key, modifiers)
|
||||
selectedGenres = nextFacetSelection(genreFacetSel, selectedGenres, key, modifiers, genreNames)
|
||||
selectedGenreYears = ({})
|
||||
selectedGenreArtists = ({})
|
||||
selectedGenreAlbums = ({})
|
||||
yearFacetSel.anchorId = ""
|
||||
artistFacetSel.anchorId = ""
|
||||
albumFacetSel.anchorId = ""
|
||||
}
|
||||
function toggleGenreYear(key, modifiers) {
|
||||
selectedGenreYears = nextFacetSelection(selectedGenreYears, key, modifiers)
|
||||
selectedGenreYears = nextFacetSelection(yearFacetSel, selectedGenreYears, key, modifiers,
|
||||
genreYearOptions)
|
||||
selectedGenreArtists = ({})
|
||||
selectedGenreAlbums = ({})
|
||||
artistFacetSel.anchorId = ""
|
||||
albumFacetSel.anchorId = ""
|
||||
}
|
||||
function toggleGenreArtist(key, modifiers) {
|
||||
selectedGenreArtists = nextFacetSelection(selectedGenreArtists, key, modifiers)
|
||||
selectedGenreArtists = nextFacetSelection(artistFacetSel, selectedGenreArtists, key,
|
||||
modifiers, genreArtistOptions)
|
||||
selectedGenreAlbums = ({})
|
||||
albumFacetSel.anchorId = ""
|
||||
}
|
||||
function toggleGenreAlbum(key, modifiers) {
|
||||
selectedGenreAlbums = nextFacetSelection(selectedGenreAlbums, key, modifiers)
|
||||
selectedGenreAlbums = nextFacetSelection(albumFacetSel, selectedGenreAlbums, key, modifiers,
|
||||
genreAlbumOptions)
|
||||
}
|
||||
function setExplorerColumns(mode) {
|
||||
if (["genre", "year", "both"].indexOf(mode) < 0 || mode === explorerColumns)
|
||||
@@ -1219,17 +1238,14 @@ Rectangle {
|
||||
readonly property int explorerFacetCount: explorerColumns === "both" ? 4 : 3
|
||||
readonly property int explorerLeadingCount: explorerColumns === "year"
|
||||
? genreYearOptions.length : genreNames.length
|
||||
function nextFacetSelection(current, key, modifiers) {
|
||||
if (key === "") return ({})
|
||||
var additive = (modifiers & Qt.ControlModifier) !== 0
|
||||
|| (modifiers & Qt.MetaModifier) !== 0
|
||||
if (!additive) {
|
||||
var only = {}; only[key] = true; return only
|
||||
/// `key` "" is the column's "All" row: it clears the column.
|
||||
function nextFacetSelection(rule, current, key, modifiers, options) {
|
||||
if (key === "") {
|
||||
rule.anchorId = ""
|
||||
return ({})
|
||||
}
|
||||
var out = Object.assign({}, current)
|
||||
if (out[key]) delete out[key]
|
||||
else out[key] = true
|
||||
return out
|
||||
return rule.nextExclusive(current, key, options || [],
|
||||
modifiers === undefined ? Qt.NoModifier : modifiers)
|
||||
}
|
||||
|
||||
// The selected artist's albums.
|
||||
|
||||
@@ -124,7 +124,10 @@ Rectangle {
|
||||
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
|
||||
root.selected = ({})
|
||||
}
|
||||
// Ctrl+A / Escape hotkey seam (AppShell duck-types these).
|
||||
// Ctrl+A / Escape hotkey seam (AppShell duck-types these). Without
|
||||
// `multiSelectOn` the Escape stack never saw this view in select mode, so
|
||||
// Escape did not leave it here while it did on every other track list.
|
||||
readonly property bool multiSelectOn: root.multiSelect
|
||||
function selectAll() {
|
||||
if (!root.multiSelect) root.setMultiSelect(true)
|
||||
root.bulkAction("select-all")
|
||||
|
||||
@@ -107,18 +107,34 @@ Rectangle {
|
||||
var p = root.livePatch[row.trackId]
|
||||
return (row.kind === "track" && p !== undefined) ? p.status : row.status
|
||||
}
|
||||
function toggleSelect(trackId) {
|
||||
var s = Object.assign({}, root.selected)
|
||||
if (s[trackId] === true)
|
||||
delete s[trackId]
|
||||
else
|
||||
s[trackId] = true
|
||||
root.selected = s
|
||||
/// The rule every multi-select list shares (controls/SelectionModel.qml):
|
||||
/// a plain or Ctrl click toggles one track, Shift adds the range from the
|
||||
/// last one — over the TRACK rows as drawn, never the album headers
|
||||
/// between them. The Slint had this surface on its Shift-range table
|
||||
/// (selection.rs SURFACE_OFFLINE); the port left it toggle-only.
|
||||
SelectionModel { id: offlineSel; idKey: "trackId" }
|
||||
function toggleSelect(trackId, mods) {
|
||||
var tracks = root.rows.filter(function (row) { return row.kind === "track" })
|
||||
root.selected = offlineSel.next(root.selected, trackId, tracks,
|
||||
mods === undefined ? Qt.NoModifier : mods)
|
||||
}
|
||||
|
||||
// Ctrl+A / Escape hotkey seam (AppShell duck-types these). The selection
|
||||
// here is always on (no select mode), so Ctrl+A ticks every track — as
|
||||
// the Slint's select_all_active_surface did for this view — and Escape
|
||||
// drops a non-empty selection.
|
||||
readonly property bool multiSelectOn: Object.keys(root.selected).length > 0
|
||||
function selectAll() {
|
||||
root.bulkAction("select-all")
|
||||
}
|
||||
function exitMultiSelectMode() {
|
||||
root.bulkAction("clear")
|
||||
}
|
||||
|
||||
function bulkAction(id) {
|
||||
if (id === "clear") {
|
||||
root.selected = ({})
|
||||
offlineSel.anchorId = ""
|
||||
return
|
||||
}
|
||||
if (id === "select-all") {
|
||||
@@ -507,8 +523,19 @@ Rectangle {
|
||||
hoverEnabled: true
|
||||
cursorShape: rowItem.isAlbum
|
||||
? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: if (!rowItem.isAlbum)
|
||||
QbzOffline.playTrack(rowItem.modelData.trackId)
|
||||
// A plain click plays; a Shift or Ctrl click is
|
||||
// a selection gesture, as in any file list, so
|
||||
// it ticks (or ranges) instead of starting
|
||||
// playback.
|
||||
onClicked: function (mouse) {
|
||||
if (rowItem.isAlbum)
|
||||
return
|
||||
if ((mouse.modifiers & (Qt.ShiftModifier | Qt.ControlModifier
|
||||
| Qt.MetaModifier)) !== 0)
|
||||
root.toggleSelect(rowItem.modelData.trackId, mouse.modifiers)
|
||||
else
|
||||
QbzOffline.playTrack(rowItem.modelData.trackId)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
@@ -526,7 +553,9 @@ Rectangle {
|
||||
anchors.centerIn: parent
|
||||
visible: !rowItem.isAlbum
|
||||
checked: rowItem.checked
|
||||
onToggled: root.toggleSelect(rowItem.modelData.trackId)
|
||||
onToggled: function (mods) {
|
||||
root.toggleSelect(rowItem.modelData.trackId, mods)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -593,11 +593,14 @@ Item {
|
||||
selected: root.nativeActive
|
||||
? cardCell.slot.selected === true
|
||||
: root.selected[cardCell.slot.id] === true
|
||||
onSelectToggled: {
|
||||
// The card's modifiers, straight through: the
|
||||
// paged grid (299dcde5b) passed NoModifier here,
|
||||
// so a Shift-click on a card was a toggle.
|
||||
onSelectToggled: function (mods) {
|
||||
if (root.nativeActive)
|
||||
root.nativeToggleSelect(cardCell.slot.nativeIndex, Qt.NoModifier)
|
||||
root.nativeToggleSelect(cardCell.slot.nativeIndex, mods)
|
||||
else
|
||||
root.toggleSelect(cardCell.slot.id, Qt.NoModifier)
|
||||
root.toggleSelect(cardCell.slot.id, mods)
|
||||
}
|
||||
// Non-select mode only — the card routes a
|
||||
// select-mode click to `selectToggled` and never
|
||||
|
||||
@@ -20,6 +20,52 @@ Item {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// ---- Shift / Ctrl on the rail's checkboxes ----
|
||||
// The selection lives in RUST (QbzLocal.tree*Select owns the set of
|
||||
// paths); what the rail holds is the one piece the rule needs on top of
|
||||
// it, the ANCHOR: the path of the last plain or Ctrl click. A Shift-click
|
||||
// looks it up among the rows drawn right now and SELECTS every row from
|
||||
// there to the clicked one (QbzLocal.treeSelectRange — additive, like
|
||||
// controls/SelectionModel.qml); an anchor that is not drawn any more
|
||||
// (collapsed, filtered away) makes it a plain toggle again.
|
||||
property string selectAnchor: ""
|
||||
Connections {
|
||||
target: root.view
|
||||
function onTreeSelectModeChanged() {
|
||||
if (!root.view.treeSelectMode)
|
||||
root.selectAnchor = ""
|
||||
}
|
||||
}
|
||||
/// The rows from `anchorPath` to `path`, inclusive, in rail order, as the
|
||||
/// `{path, isFolder}` pairs treeSelectRange takes. [] when either is not
|
||||
/// drawn.
|
||||
function rangeNodes(rows, anchorPath, path) {
|
||||
var from = -1
|
||||
var to = -1
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (rows[i].path === anchorPath) from = i
|
||||
if (rows[i].path === path) to = i
|
||||
}
|
||||
if (from < 0 || to < 0)
|
||||
return []
|
||||
var out = []
|
||||
for (var j = Math.min(from, to); j <= Math.max(from, to); j++)
|
||||
out.push({ "path": rows[j].path, "isFolder": rows[j].isFolder === true })
|
||||
return out
|
||||
}
|
||||
function toggleNodeSelect(node, mods) {
|
||||
if (((mods || 0) & Qt.ShiftModifier) !== 0 && root.selectAnchor !== "") {
|
||||
var nodes = root.rangeNodes(root.view.tree || [], root.selectAnchor, node.path)
|
||||
if (nodes.length > 0) {
|
||||
QbzLocal.treeSelectRange(JSON.stringify(nodes))
|
||||
return
|
||||
}
|
||||
}
|
||||
if (node.isFolder) QbzLocal.treeToggleFolderSelect(node.path)
|
||||
else QbzLocal.treeToggleTrackSelect(node.path)
|
||||
root.selectAnchor = node.path
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
@@ -149,16 +195,9 @@ Item {
|
||||
selectMode: root.view.treeSelectMode
|
||||
onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded)
|
||||
onActivated: root.view.selectFolder(modelData.path)
|
||||
// NO Shift-range here, deliberately: this rail's selection
|
||||
// lives in RUST (QbzLocal.treeToggle*Select owns the set),
|
||||
// not in a QML map, so controls/SelectionModel.qml has
|
||||
// nothing to hold an anchor against. Giving the tree ranges
|
||||
// means teaching the Rust side about an anchor, which is a
|
||||
// change of its own and not part of this port.
|
||||
onToggleSelect: {
|
||||
if (modelData.isFolder) QbzLocal.treeToggleFolderSelect(modelData.path)
|
||||
else QbzLocal.treeToggleTrackSelect(modelData.path)
|
||||
}
|
||||
// Shift ranges from the rail's anchor (see
|
||||
// `toggleNodeSelect` at the top of this file).
|
||||
onToggleSelect: function (mods) { root.toggleNodeSelect(modelData, mods) }
|
||||
}
|
||||
}
|
||||
QbzScrollBar {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
// Slint declares this inline inside TreeRow; the Qt port also needs it on
|
||||
// the album cards and the track rows in multi-select, so it is its own file
|
||||
// (three call sites, one shape).
|
||||
//
|
||||
// `toggled` carries the click's modifiers: this disc IS a select-mode click
|
||||
// target, and one that dropped them turned every Shift-click on it into a
|
||||
// plain toggle (controls/SelectionModel.qml).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
@@ -16,7 +20,7 @@ Rectangle {
|
||||
property bool on: false
|
||||
property bool partial: false
|
||||
property int diameter: 13
|
||||
signal toggled()
|
||||
signal toggled(int modifiers)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -52,6 +56,6 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled()
|
||||
onClicked: function (mouse) { root.toggled(mouse.modifiers) }
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,15 @@ Rectangle {
|
||||
if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y)
|
||||
return
|
||||
}
|
||||
// In select mode a Shift or Ctrl click on the body is a selection
|
||||
// gesture, as in any file list: it ticks (or ranges) the row
|
||||
// instead of opening the folder. A plain click still opens it.
|
||||
if (root.selectMode
|
||||
&& (mouse.modifiers & (Qt.ShiftModifier | Qt.ControlModifier
|
||||
| Qt.MetaModifier)) !== 0) {
|
||||
root.toggleSelect(mouse.modifiers)
|
||||
return
|
||||
}
|
||||
if (root.node.isFolder) root.activated()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,9 @@ Rectangle {
|
||||
}
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button !== Qt.LeftButton) return
|
||||
if (root.selectMode) QbzMyQbz.detailToggleItemSelect(root.itemPosition)
|
||||
if (root.selectMode)
|
||||
QbzMyQbz.detailToggleItemSelect(root.itemPosition,
|
||||
(mouse.modifiers & Qt.ShiftModifier) !== 0)
|
||||
else root.openThis()
|
||||
}
|
||||
}
|
||||
@@ -247,7 +249,10 @@ Rectangle {
|
||||
visible: root.selectMode
|
||||
anchors.centerIn: parent
|
||||
on: root.selected
|
||||
onToggled: QbzMyQbz.detailToggleItemSelect(root.itemPosition)
|
||||
onToggled: function (mods) {
|
||||
QbzMyQbz.detailToggleItemSelect(root.itemPosition,
|
||||
(mods & Qt.ShiftModifier) !== 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +300,10 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: artArea
|
||||
anchors.fill: parent
|
||||
// In select mode the whole row selects (Shift / Ctrl
|
||||
// included): the artwork, title and artist stop playing
|
||||
// or navigating so the click reaches the row body.
|
||||
enabled: !root.selectMode
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzMyQbz.playItem(root.sourceItemId)
|
||||
@@ -321,6 +330,7 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: titleArea
|
||||
anchors.fill: parent
|
||||
enabled: !root.selectMode
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.openThis()
|
||||
@@ -343,6 +353,7 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: subArea
|
||||
anchors.fill: parent
|
||||
enabled: !root.selectMode
|
||||
hoverEnabled: true
|
||||
cursorShape: root.subtitleIsLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
|
||||
@@ -1193,7 +1193,10 @@ Rectangle {
|
||||
// selection checkbox rather than bolt one on.
|
||||
selectMode: root.selectMode
|
||||
selected: gcell.cSelected
|
||||
onSelectToggled: QbzMyQbz.detailToggleItemSelect(gcell.cPosition)
|
||||
onSelectToggled: function (mods) {
|
||||
QbzMyQbz.detailToggleItemSelect(gcell.cPosition,
|
||||
(mods & Qt.ShiftModifier) !== 0)
|
||||
}
|
||||
// Card body click, non-select mode only (the card
|
||||
// routes a select-mode click to `selectToggled`).
|
||||
onOpenRequested: QbzMyQbz.openItem(gcell.cSource,
|
||||
|
||||
@@ -365,6 +365,11 @@ pub mod qbz_local {
|
||||
/// Track checkbox: toggle one row by file path.
|
||||
#[qinvokable]
|
||||
fn tree_toggle_track_select(self: Pin<&mut QbzLocal>, path: QString);
|
||||
/// Shift-click on a rail checkbox: SELECT every visible row from the
|
||||
/// anchor to the clicked one. `nodes_json` =
|
||||
/// `[{"path": …, "isFolder": …}]` in rail order.
|
||||
#[qinvokable]
|
||||
fn tree_select_range(self: Pin<&mut QbzLocal>, nodes_json: QString);
|
||||
/// Tree-rail bulk bar.
|
||||
#[qinvokable]
|
||||
fn folders_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
|
||||
@@ -1757,6 +1762,10 @@ impl qbz_local::QbzLocal {
|
||||
crate::local_bulk::toggle_track_select(path.to_string());
|
||||
}
|
||||
|
||||
pub fn tree_select_range(self: Pin<&mut Self>, nodes_json: QString) {
|
||||
crate::local_bulk::select_tree_range(nodes_json.to_string());
|
||||
}
|
||||
|
||||
pub fn folders_bulk_action(self: Pin<&mut Self>, action: QString) {
|
||||
crate::local_bulk::folders_bulk_action(action.to_string());
|
||||
}
|
||||
|
||||
+142
-23
@@ -24,7 +24,9 @@
|
||||
//! This file is the Qt-facing half of the tree selection; the state and the
|
||||
//! blocking mutators live in `local_tree.rs`, next to the tree they annotate.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_library::LocalTrack;
|
||||
@@ -61,29 +63,124 @@ fn publish_selection() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rail header toggle. Leaving select mode drops the selection, so the
|
||||
/// republish is what hollows every checkbox again.
|
||||
/// republish is what hollows every checkbox again. The drop waits behind any
|
||||
/// checkbox click still queued, or that click would tick a row again after it.
|
||||
pub fn set_select_mode(on: bool) {
|
||||
tree::set_tree_select_mode(on);
|
||||
publish_selection();
|
||||
if on {
|
||||
publish_selection();
|
||||
} else {
|
||||
run_tree_select(TreeSelectOp::LeaveSelectMode);
|
||||
}
|
||||
}
|
||||
|
||||
/// One tree-selection mutation, as the user issued it.
|
||||
enum TreeSelectOp {
|
||||
Folder(String),
|
||||
Track(String),
|
||||
Range(Vec<(String, bool)>),
|
||||
SelectAll,
|
||||
Clear,
|
||||
LeaveSelectMode,
|
||||
}
|
||||
|
||||
/// The rail's selection mutations run ONE AT A TIME, in the order they were
|
||||
/// clicked. Each is a blocking DB read, and they used to be spawned side by
|
||||
/// side: harmless while every click was a toggle of its own row, but a
|
||||
/// Shift-range that finished before the plain click that set its anchor would
|
||||
/// see that folder already selected by the range — and the late toggle would
|
||||
/// then UNselect it. Clicks push here on the Qt thread, so the queue order is
|
||||
/// the click order; a single drainer works through it.
|
||||
static TREE_SELECT_OPS: Mutex<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.
|
||||
pub fn toggle_folder_select(path: String) {
|
||||
crate::spawn(async move {
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || tree::toggle_folder_select_blocking(&path)).await;
|
||||
publish_selection();
|
||||
});
|
||||
run_tree_select(TreeSelectOp::Folder(path));
|
||||
}
|
||||
|
||||
/// Track checkbox: a deselect is pure state, a select resolves the record
|
||||
/// from the parent folder listing — blocking either way.
|
||||
pub fn toggle_track_select(path: String) {
|
||||
crate::spawn(async move {
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || tree::toggle_track_select_blocking(&path)).await;
|
||||
publish_selection();
|
||||
});
|
||||
run_tree_select(TreeSelectOp::Track(path));
|
||||
}
|
||||
|
||||
/// Shift-click on a rail checkbox: `nodes_json` is the visible rows from the
|
||||
/// anchor to the clicked one, `[{"path": …, "isFolder": …}]`, all SELECTED
|
||||
/// (`local_tree::select_nodes_blocking`).
|
||||
pub fn select_tree_range(nodes_json: String) {
|
||||
let nodes = parse_tree_range(&nodes_json);
|
||||
if nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
run_tree_select(TreeSelectOp::Range(nodes));
|
||||
}
|
||||
|
||||
fn parse_tree_range(json: &str) -> Vec<(String, bool)> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Node {
|
||||
#[serde(default)]
|
||||
path: String,
|
||||
#[serde(rename = "isFolder", default)]
|
||||
is_folder: bool,
|
||||
}
|
||||
serde_json::from_str::<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) {
|
||||
match action.as_str() {
|
||||
"select-all" => {
|
||||
// Two-way: "check all" un-checks when everything already is.
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(tree::tree_select_all_blocking).await;
|
||||
publish_selection();
|
||||
});
|
||||
}
|
||||
"clear" => {
|
||||
tree::tree_clear_selection();
|
||||
publish_selection();
|
||||
// Two-way: "check all" un-checks when everything already is. In
|
||||
// the same queue as the checkboxes, so it sees them all applied.
|
||||
run_tree_select(TreeSelectOp::SelectAll);
|
||||
}
|
||||
"clear" => run_tree_select(TreeSelectOp::Clear),
|
||||
_ => {
|
||||
let rows = tree::tree_selected_snapshot();
|
||||
crate::spawn(async move {
|
||||
@@ -336,3 +428,30 @@ async fn enqueue_rows(rows: Vec<LocalTrack>, mode: &str) {
|
||||
}
|
||||
crate::playback_qt::publish_queue(&runtime).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tree_range_tests {
|
||||
use super::parse_tree_range;
|
||||
|
||||
#[test]
|
||||
fn range_nodes_keep_their_order_and_kind() {
|
||||
let nodes = parse_tree_range(
|
||||
r#"[{"path":"/m/a","isFolder":true},{"path":"/m/a/1.flac","isFolder":false},{"path":"/m/b","isFolder":true}]"#,
|
||||
);
|
||||
assert_eq!(
|
||||
nodes,
|
||||
vec![
|
||||
("/m/a".to_string(), true),
|
||||
("/m/a/1.flac".to_string(), false),
|
||||
("/m/b".to_string(), true),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_nodes_without_a_path_or_bad_json_are_dropped() {
|
||||
assert!(parse_tree_range("not json").is_empty());
|
||||
let nodes = parse_tree_range(r#"[{"isFolder":true},{"path":"/m/c"}]"#);
|
||||
assert_eq!(nodes, vec![("/m/c".to_string(), false)]);
|
||||
}
|
||||
}
|
||||
+107
-18
@@ -260,6 +260,33 @@ fn sel<R>(f: impl FnOnce(&mut HashMap<String, LocalTrack>) -> R) -> R {
|
||||
f(guard.get_or_insert_with(HashMap::new))
|
||||
}
|
||||
|
||||
/// How many selected keys lie under the folder node `folder`. `paths` is the
|
||||
/// SORTED selection.
|
||||
///
|
||||
/// Ordinary tracks are keyed by their file path, `<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
|
||||
/// per publish; each folder's "how many of my tracks are selected" is then a
|
||||
/// binary-searched range and each track row a binary search — instead of the
|
||||
@@ -282,14 +309,7 @@ fn annotate(nodes: Vec<TreeNode>) -> Vec<TreeNodeOut> {
|
||||
.into_iter()
|
||||
.map(|node| {
|
||||
let (selected, select_state) = if node.is_folder {
|
||||
// Byte-order range over "<path>/…": the upper bound is the
|
||||
// same prefix with its trailing '/' (0x2F) bumped to '0'
|
||||
// (0x30), which is the next byte value.
|
||||
let lo = format!("{}/", node.path);
|
||||
let hi = format!("{}0", node.path);
|
||||
let start = paths.partition_point(|p| p.as_str() < lo.as_str());
|
||||
let end = paths.partition_point(|p| p.as_str() < hi.as_str());
|
||||
let under = (end - start) as u32;
|
||||
let under = selected_under(&paths, &node.path);
|
||||
let st = if under == 0 {
|
||||
0
|
||||
} else if node.track_count > 0 && under >= node.track_count {
|
||||
@@ -364,25 +384,54 @@ pub fn toggle_track_select_blocking(path: &str) {
|
||||
if sel(|s| s.remove(path)).is_some() {
|
||||
return;
|
||||
}
|
||||
// SACD leaves deliberately carry their authoritative virtual
|
||||
// `sacd:<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() {
|
||||
if let Some(track) = resolve_track_record(path) {
|
||||
sel(|s| {
|
||||
s.insert(path.to_string(), track);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// The library record behind a track leaf's path.
|
||||
///
|
||||
/// SACD leaves deliberately carry their authoritative virtual
|
||||
/// `sacd:<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)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let tracks = with_db(|db| db.list_folder_tracks(&parent, false)).unwrap_or_default();
|
||||
if let Some(t) = tracks.into_iter().find(|t| t.file_path == path) {
|
||||
sel(|s| {
|
||||
s.insert(path.to_string(), t);
|
||||
});
|
||||
tracks.into_iter().find(|t| t.file_path == path)
|
||||
}
|
||||
|
||||
/// Shift-click in the rail: SELECT — never toggle — every node the range
|
||||
/// covers (`nodes` = `(path, is_folder)` of the visible rows from the anchor
|
||||
/// to the clicked row). A folder brings its whole subtree and a track its own
|
||||
/// record. Inserting is idempotent, so a folder and its expanded children
|
||||
/// sharing the range, or rows that were already ticked, simply stay ticked:
|
||||
/// a range only ever adds, like every other select-mode surface.
|
||||
pub fn select_nodes_blocking(nodes: &[(String, bool)]) {
|
||||
for (path, is_folder) in nodes {
|
||||
if *is_folder {
|
||||
let tracks =
|
||||
with_db(|db| db.list_folder_tracks_recursive(path, false)).unwrap_or_default();
|
||||
sel(|s| {
|
||||
for t in tracks {
|
||||
s.insert(t.file_path.clone(), t);
|
||||
}
|
||||
});
|
||||
} else if !sel(|s| s.contains_key(path.as_str())) {
|
||||
if let Some(track) = resolve_track_record(path) {
|
||||
sel(|s| {
|
||||
s.insert(path.clone(), track);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,3 +509,43 @@ pub fn load_folder_detail_blocking(path: &str) -> FolderDetail {
|
||||
tracks: rows,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::selected_under;
|
||||
|
||||
fn sorted(keys: &[&str]) -> Vec<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);
|
||||
}
|
||||
}
|
||||
@@ -162,9 +162,11 @@ pub mod qbz_myqbz_bridge {
|
||||
/// Leaving select mode clears the selection.
|
||||
#[qinvokable]
|
||||
fn detail_toggle_select_mode(self: Pin<&mut QbzMyQbz>);
|
||||
/// `position` is the 0-based persisted position (the stable key).
|
||||
/// `position` is the 0-based persisted position (the stable key);
|
||||
/// `shift` = the click held Shift, i.e. add the range from the last
|
||||
/// plain click (myqbz_detail_qt::toggle_item_select).
|
||||
#[qinvokable]
|
||||
fn detail_toggle_item_select(self: Pin<&mut QbzMyQbz>, position: i32);
|
||||
fn detail_toggle_item_select(self: Pin<&mut QbzMyQbz>, position: i32, shift: bool);
|
||||
/// Resets type/source/sort; the search text is left INTACT
|
||||
/// (myqbz_detail.rs:540-541).
|
||||
#[qinvokable]
|
||||
@@ -472,8 +474,8 @@ impl qbz_myqbz_bridge::QbzMyQbz {
|
||||
crate::myqbz_detail_qt::toggle_select_mode();
|
||||
}
|
||||
|
||||
pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32) {
|
||||
crate::myqbz_detail_qt::toggle_item_select(position);
|
||||
pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32, shift: bool) {
|
||||
crate::myqbz_detail_qt::toggle_item_select(position, shift);
|
||||
}
|
||||
|
||||
pub fn detail_reset_filters(self: Pin<&mut Self>) {
|
||||
|
||||
@@ -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).
|
||||
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
|
||||
/// whether a rebuild is a RE-DERIVE (clears the selection) or a row patch.
|
||||
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
|
||||
// bulk bar can never claim a selection the rows do not show.
|
||||
SELECTED.lock().unwrap().clear();
|
||||
clear_select_anchor();
|
||||
}
|
||||
|
||||
let view = filtered_sorted(d);
|
||||
@@ -1431,6 +1469,7 @@ fn reset() {
|
||||
// `apply` seeds this one back from it.
|
||||
OPEN_ROWS.lock().unwrap().clear();
|
||||
SELECTED.lock().unwrap().clear();
|
||||
clear_select_anchor();
|
||||
*LAST_FILTER_KEY.lock().unwrap() = None;
|
||||
// Close the persist gate until `apply` restores this collection's prefs.
|
||||
PREFS_HYDRATED.store(false, Ordering::SeqCst);
|
||||
@@ -2158,6 +2197,7 @@ pub(crate) fn toggle_select_mode() {
|
||||
let on = !d.select_mode;
|
||||
if !on {
|
||||
SELECTED.lock().unwrap().clear();
|
||||
clear_select_anchor();
|
||||
for row in d.items.iter_mut() {
|
||||
row.selected = false;
|
||||
}
|
||||
@@ -2169,24 +2209,19 @@ pub(crate) fn toggle_select_mode() {
|
||||
publish(&doc);
|
||||
}
|
||||
|
||||
/// Toggle one row's selection by its stable POSITION, then recount. A row patch
|
||||
/// — never a re-derive.
|
||||
pub(crate) fn toggle_item_select(position: i32) {
|
||||
/// One select-mode click on a row, by its stable POSITION: a toggle, or with
|
||||
/// `shift` the range from the anchor (`apply_item_click`). Then every drawn
|
||||
/// row's tick and the count follow the set. A row patch — never a re-derive.
|
||||
pub(crate) fn toggle_item_select(position: i32, shift: bool) {
|
||||
let doc = with_doc(|d| {
|
||||
let now_selected = {
|
||||
let mut set = SELECTED.lock().unwrap();
|
||||
if set.contains(&position) {
|
||||
set.remove(&position);
|
||||
false
|
||||
} else {
|
||||
set.insert(position);
|
||||
true
|
||||
}
|
||||
};
|
||||
if let Some(row) = d.items.iter_mut().find(|r| r.position == position) {
|
||||
row.selected = now_selected;
|
||||
let order: Vec<i32> = d.items.iter().map(|r| r.position).collect();
|
||||
let mut set = SELECTED.lock().unwrap();
|
||||
let mut anchor = SELECT_ANCHOR.lock().unwrap();
|
||||
apply_item_click(&order, &mut set, &mut anchor, position, shift);
|
||||
for row in d.items.iter_mut() {
|
||||
row.selected = set.contains(&row.position);
|
||||
}
|
||||
d.selected_count = SELECTED.lock().unwrap().len() as i32;
|
||||
d.selected_count = set.len() as i32;
|
||||
d.clone()
|
||||
});
|
||||
publish(&doc);
|
||||
@@ -2197,6 +2232,7 @@ pub(crate) fn toggle_item_select(position: i32) {
|
||||
pub(crate) fn clear_selection() {
|
||||
let doc = with_doc(|d| {
|
||||
SELECTED.lock().unwrap().clear();
|
||||
clear_select_anchor();
|
||||
for row in d.items.iter_mut() {
|
||||
row.selected = false;
|
||||
}
|
||||
@@ -2374,6 +2410,7 @@ pub(crate) fn teardown() {
|
||||
RESOLVE_CACHE.lock().unwrap().clear();
|
||||
OPEN_ROWS.lock().unwrap().clear();
|
||||
SELECTED.lock().unwrap().clear();
|
||||
clear_select_anchor();
|
||||
*LAST_FILTER_KEY.lock().unwrap() = None;
|
||||
PREFS_HYDRATED.store(false, Ordering::SeqCst);
|
||||
let doc = with_doc(|d| {
|
||||
@@ -2495,6 +2532,45 @@ pub(crate) fn bulk_action(id: String) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn picked(set: &HashSet<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(
|
||||
position: i32,
|
||||
title: &str,
|
||||
|
||||
@@ -89,8 +89,11 @@ case "$arch" in
|
||||
else jobs="$(nproc)"; fi
|
||||
say "RAM ${mem_mb} MB -> CARGO_BUILD_JOBS=${jobs}"
|
||||
# No RUSTFLAGS, no mold, stable: same cache rule as qt-run.sh.
|
||||
# The target qt-cargo.py builds in (scripts/qt-target.py), resolved once.
|
||||
CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)"
|
||||
export CARGO_TARGET_DIR
|
||||
CARGO_BUILD_JOBS="$jobs" CARGO_INCREMENTAL=0 python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbz-qt
|
||||
install -Dm755 "crates/target/release/qbz" "$OUT"
|
||||
install -Dm755 "$CARGO_TARGET_DIR/release/qbz" "$OUT"
|
||||
;;
|
||||
x86_64 | amd64)
|
||||
die "CROSS mode is not supported for the Qt binary (see the header: cxx-qt \
|
||||
|
||||
@@ -48,8 +48,12 @@ case "$arch" in
|
||||
else
|
||||
echo "[qbzd-aarch64] non-apt distro: install the equivalents of: ${DEPS[*]}" >&2
|
||||
fi
|
||||
# This worktree's own target (scripts/qt-target.py): Cargo's default in a
|
||||
# plain checkout, a per-worktree sibling when the host shares one target.
|
||||
CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)"
|
||||
export CARGO_TARGET_DIR
|
||||
( cd crates && cargo build --release -p qbzd )
|
||||
install -Dm755 "crates/target/release/qbzd" "$OUT"
|
||||
install -Dm755 "$CARGO_TARGET_DIR/release/qbzd" "$OUT"
|
||||
;;
|
||||
x86_64 | amd64)
|
||||
echo "[qbzd-aarch64] CROSS-compile from $arch via cross (Docker)"
|
||||
@@ -65,8 +69,12 @@ case "$arch" in
|
||||
export CROSS_CONTAINER_OPTS="${CROSS_CONTAINER_OPTS:---memory=8g --memory-swap=12g}"
|
||||
# crates/Cross.toml injects the arm64 dev libs into the image (the daemon's
|
||||
# set; the Qt desktop is not cross-built, see build-aarch64-linux.sh).
|
||||
# cross mounts CARGO_TARGET_DIR into the container: the worktree's own
|
||||
# target here too, never one another worktree also builds into.
|
||||
CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)"
|
||||
export CARGO_TARGET_DIR
|
||||
( cd crates && cross build --release --target "$TARGET" -p qbzd )
|
||||
install -Dm755 "crates/target/$TARGET/release/qbzd" "$OUT"
|
||||
install -Dm755 "$CARGO_TARGET_DIR/$TARGET/release/qbzd" "$OUT"
|
||||
;;
|
||||
*)
|
||||
echo "[qbzd-aarch64] ERROR: unsupported build host arch: $arch" >&2
|
||||
|
||||
@@ -31,7 +31,7 @@ fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required"
|
||||
command -v python3 >/dev/null 2>&1 || fail "python3 is required (status/ping/info shape checks)"
|
||||
command -v timeout >/dev/null 2>&1 || fail "timeout (coreutils) is required"
|
||||
[ -x "$QBZD_BIN" ] || fail "qbzd binary not found/executable at $QBZD_BIN -- build it first (release, on its own: cargo build --release -p qbzd)"
|
||||
[ -x "$QBZD_BIN" ] || fail "qbzd binary not found/executable at $QBZD_BIN -- build it first (release, on its own, into this worktree's target: python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbzd)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolated scratch profile root. NEVER the real daemon/desktop roots: dirs::
|
||||
|
||||
@@ -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
|
||||
function mediaCancelQuickConnect() { pairingCancels++ }
|
||||
function mediaQuickConnect(url) {}
|
||||
|
||||
// Folder tree rail (LocalTreeRail / TreeRow).
|
||||
property bool localTreeLoading: false
|
||||
property int localTreeSelectedCount: 0
|
||||
property var treeCalls: []
|
||||
function treeToggleFolderSelect(path) { treeCalls = treeCalls.concat(["folder:" + path]) }
|
||||
function treeToggleTrackSelect(path) { treeCalls = treeCalls.concat(["track:" + path]) }
|
||||
function treeSelectRange(json) { treeCalls = treeCalls.concat(["range:" + json]) }
|
||||
function treeToggle(path, open) {}
|
||||
function treeCollapseAll() {}
|
||||
function treeSearch(query) {}
|
||||
function foldersBulkAction(action) {}
|
||||
function playFolder(path) {}
|
||||
function enqueue(kind, id, mode) {}
|
||||
}
|
||||
@@ -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 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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,15 @@ Use this entry point for Qt builds/tests, including direct Cargo invocations:
|
||||
No target cleanup, profile changes, or RUSTFLAGS: a content-derived, unused
|
||||
C++ define invalidates cc-rs consumers when the SDK changes, in either profile
|
||||
and with any Rust toolchain. Existing CXXFLAGS (including MSVC flags) survive.
|
||||
|
||||
Where Cargo builds follows scripts/qt-target.py: a host-level target shared by
|
||||
every worktree becomes this worktree's own sibling of it, so a direct build or
|
||||
test here cannot replace a binary another worktree built. An explicit
|
||||
CARGO_TARGET_DIR, or a target inside the checkout, is left to Cargo.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -112,12 +118,56 @@ def cargo_environment(env):
|
||||
return result, version, fingerprint
|
||||
|
||||
|
||||
def load_targets():
|
||||
# A hyphenated sibling loaded by path: without this, every build would leave
|
||||
# an untracked scripts/__pycache__ in the checkout.
|
||||
sys.dont_write_bytecode = True
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"qt_target", Path(__file__).with_name("qt-target.py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def manifest_argument(arguments):
|
||||
for index, argument in enumerate(arguments):
|
||||
if argument == "--manifest-path" and index + 1 < len(arguments):
|
||||
return Path(arguments[index + 1])
|
||||
if argument.startswith("--manifest-path="):
|
||||
return Path(argument.split("=", 1)[1])
|
||||
return None
|
||||
|
||||
|
||||
def target_environment(env, arguments, targets=None):
|
||||
"""`env` with CARGO_TARGET_DIR set to this worktree's own target, when the
|
||||
target Cargo would use is shared with other worktrees (qt-target.py)."""
|
||||
targets = targets or load_targets()
|
||||
if targets.explicit(env):
|
||||
return env
|
||||
try:
|
||||
metadata = targets.cargo_metadata(manifest_argument(arguments), dict(env))
|
||||
configured = Path(metadata["target_directory"]).resolve()
|
||||
target = targets.worktree_target(
|
||||
configured, targets.worktree_root(metadata["workspace_root"]), env)
|
||||
except (OSError, subprocess.SubprocessError, KeyError, ValueError) as error:
|
||||
print(f"[qt-cargo] target not resolved ({error}); Cargo's own choice stands",
|
||||
file=sys.stderr, flush=True)
|
||||
return env
|
||||
if target == configured:
|
||||
return env
|
||||
result = dict(env)
|
||||
result["CARGO_TARGET_DIR"] = str(target)
|
||||
print(f"[qt-cargo] worktree target {target}", file=sys.stderr, flush=True)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
raise RuntimeError("usage: qt-cargo.py <cargo subcommand> [arguments...]")
|
||||
env, version, fingerprint = cargo_environment(os.environ)
|
||||
print(f"[qt-cargo] Qt {version}, SDK {fingerprint}, qmake={env['QMAKE']}",
|
||||
file=sys.stderr, flush=True)
|
||||
env = target_environment(env, sys.argv[2:])
|
||||
# subprocess.run, NOT os.execvpe: on Windows os.exec* is emulated (spawn +
|
||||
# terminate) and segfaulted here — the release-windows build died with
|
||||
# "Segmentation fault ... exit code 139" right after this line on
|
||||
|
||||
+35
-2
@@ -33,6 +33,16 @@
|
||||
# run BEFORE the build — failing in one second beats failing after two minutes,
|
||||
# or worse, at the owner's smoke. NO_AUDIT=1 skips them.
|
||||
#
|
||||
# ─── EACH WORKTREE BUILDS IN ITS OWN TARGET ─────────────────────────────────
|
||||
# scripts/qt-target.py decides where. With Cargo's default target (inside the
|
||||
# checkout: macOS, CI) nothing changes. When the host configures ONE target
|
||||
# outside the checkout for every worktree, this worktree builds in its own
|
||||
# sibling of it, `<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]
|
||||
# DEBUG=1 ./scripts/qt-run.sh # debug profile (the binary the gate uses)
|
||||
# NORUN=1 ./scripts/qt-run.sh # build only, don't exec
|
||||
@@ -127,7 +137,6 @@ if [[ "${DEBUG:-0}" == 1 ]]; then
|
||||
else
|
||||
PROFILE=release; PROFILE_ARGS=(--release)
|
||||
fi
|
||||
BIN="${CARGO_TARGET_DIR:-crates/target}/${PROFILE}/qbz"
|
||||
|
||||
# --- ONE BUILD AT A TIME, BOX-WIDE ------------------------------------------
|
||||
# Not paranoia: a Slint `qbz-ui` rustc in another worktree peaks 20-30 GB on a
|
||||
@@ -141,6 +150,13 @@ if [[ "${FORCE:-0}" != 1 ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Where this worktree builds (see the header) -----------------------------
|
||||
# The PHYSICAL path: moc/cc-rs resolve includes wrongly through the crates/target
|
||||
# symlink.
|
||||
CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)" || die "could not resolve the Cargo target directory"
|
||||
export CARGO_TARGET_DIR
|
||||
BIN="${CARGO_TARGET_DIR}/${PROFILE}/qbz"
|
||||
|
||||
# --- The QML audits (fail fast; cargo cannot see QML) ------------------------
|
||||
if [[ "${NO_AUDIT:-0}" != 1 ]]; then
|
||||
if [[ -d "${AUDIT_DIR}" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
@@ -198,6 +214,17 @@ eta_secs=0
|
||||
|
||||
avail_mb=$(mem_avail_mb)
|
||||
say "profile=${PROFILE} jobs=${CARGO_BUILD_JOBS} avail=${avail_mb}MB rustflags='${RUSTFLAGS:-}'"
|
||||
say "target=${CARGO_TARGET_DIR}"
|
||||
|
||||
# A worktree target's first build of a profile starts from the shared target's
|
||||
# artifacts (copy-on-write, nothing duplicated) instead of from nothing. After
|
||||
# the one-build guard on purpose: never clone a target another build is writing.
|
||||
seed_profiles=("${PROFILE}")
|
||||
[[ "${TEST:-0}" == 1 && "${PROFILE}" != debug ]] && seed_profiles+=(debug)
|
||||
if [[ -z "$(other_builds)" ]]; then
|
||||
python3 scripts/qt-target.py seed --target-dir "${CARGO_TARGET_DIR}" "${seed_profiles[@]}" \
|
||||
|| warn "could not seed ${CARGO_TARGET_DIR}; Cargo builds it from scratch"
|
||||
fi
|
||||
|
||||
# --- Start banner ------------------------------------------------------------
|
||||
build_start=$(date +%s)
|
||||
@@ -232,6 +259,11 @@ fi
|
||||
# --- The build ---------------------------------------------------------------
|
||||
python3 scripts/qt-cargo.py build "${PROFILE_ARGS[@]}" --manifest-path crates/Cargo.toml -p qbz-qt
|
||||
|
||||
# The familiar crates/target path follows the target this worktree just built,
|
||||
# and only after a successful build.
|
||||
python3 scripts/qt-target.py link --target-dir "${CARGO_TARGET_DIR}" \
|
||||
|| warn "crates/target was not re-pointed; the binary is ${BIN}"
|
||||
|
||||
# --- Stop the ticker, record the duration, print the final banner ------------
|
||||
[[ -n "${tick_pid}" ]] && { kill "${tick_pid}" 2>/dev/null || true; wait "${tick_pid}" 2>/dev/null || true; }
|
||||
trap - EXIT
|
||||
@@ -239,6 +271,7 @@ build_secs=$(( $(date +%s) - build_start ))
|
||||
mkdir -p "${eta_dir}" 2>/dev/null && printf '%s\n' "${build_secs}" > "${eta_file}" 2>/dev/null || true
|
||||
printf '%s[qt-run] ✔ build finished %s · took %s (%s)%s\n' \
|
||||
"${C_BOLD}${C_GRN}" "$(date '+%H:%M:%S')" "$(fmt_dur "${build_secs}")" "${PROFILE}" "${C_RST}"
|
||||
say "binary=${BIN}"
|
||||
|
||||
# --- Tests (opt-in) ----------------------------------------------------------
|
||||
if [[ "${TEST:-0}" == 1 ]]; then
|
||||
@@ -260,7 +293,7 @@ if [[ "${SMOKE:-0}" == 1 ]]; then
|
||||
# template path so the two agree.
|
||||
log="$(mktemp "${TMPDIR:-/tmp}/qbz-qt-smoke-XXXXXX")"
|
||||
say "offscreen smoke (75s max) → ${log}"
|
||||
QT_QPA_PLATFORM=offscreen RUST_LOG=info run_timeout 75 "./${BIN}" > "${log}" 2>&1 || true
|
||||
QT_QPA_PLATFORM=offscreen RUST_LOG=info run_timeout 75 "${BIN}" > "${log}" 2>&1 || true
|
||||
# A log this short means the process never really started (the macOS
|
||||
# `timeout`-not-found case did exactly that). Zero complaints in an empty log
|
||||
# is not a pass.
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
set -euo pipefail
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
|
||||
export QBZ_PREBUILT_SHADERS=1
|
||||
# The same target qt-run.sh builds in: this worktree's own when the host shares
|
||||
# one target across worktrees, Cargo's default otherwise (scripts/qt-target.py).
|
||||
CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)"
|
||||
export CARGO_TARGET_DIR
|
||||
bash scripts/test-search-local-qml.sh
|
||||
bash scripts/test-updates-qml.sh
|
||||
bash scripts/test-playback-cache-qml.sh
|
||||
@@ -11,6 +15,7 @@ bash scripts/test-compact-seek-qml.sh
|
||||
bash scripts/test-library-folders-qml.sh
|
||||
bash scripts/test-array-model-qml.sh
|
||||
bash scripts/test-keyed-model-qml.sh
|
||||
bash scripts/test-multi-select-qml.sh
|
||||
bash scripts/test-wallpaper-field-qml.sh
|
||||
bash scripts/test-card-menu-qml.sh
|
||||
bash scripts/test-orbit-qml.sh
|
||||
@@ -22,7 +27,7 @@ node scripts/test_qt_kiosk_navigation.mjs
|
||||
node scripts/test_qt_kiosk_feedback.mjs
|
||||
node scripts/test_qt_exclusive_gate.mjs
|
||||
python3 scripts/qt-cargo.py test --manifest-path crates/Cargo.toml -p qbz-qt --no-fail-fast
|
||||
target_dir="${CARGO_TARGET_DIR:-$PWD/crates/target}"
|
||||
target_dir="$CARGO_TARGET_DIR"
|
||||
logs="$(mktemp -d "${TMPDIR:-/tmp}/qbz-qt-gate-XXXXXX")"
|
||||
printf '[qt-gate] startup logs: %s\n' "$logs"
|
||||
for profile in debug release; do
|
||||
@@ -31,5 +36,7 @@ for profile in debug release; do
|
||||
python3 scripts/qt-cargo.py build "${args[@]}" --manifest-path crates/Cargo.toml -p qbz-qt
|
||||
python3 scripts/qt-smoke.py "$target_dir/$profile/qbz" --log "$logs/$profile.log"
|
||||
done
|
||||
python3 scripts/qt-target.py link --target-dir "$target_dir" \
|
||||
|| printf '[qt-gate] crates/target not re-pointed; binaries are in %s\n' "$target_dir" >&2
|
||||
# A healthy offscreen bus cannot expose Qt's synchronous xcb D-Bus startup.
|
||||
python3 scripts/qt-smoke.py "$target_dir/release/qbz" --silent-bus --log "$logs/release-silent-bus.log"
|
||||
@@ -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)
|
||||
Executable
+21
@@ -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
|
||||
@@ -1,9 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SDK-cache and crash-aware smoke regressions; no Qt or Cargo build needed."""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import sys
|
||||
@@ -21,6 +26,222 @@ def load(name, filename):
|
||||
|
||||
qt = load("qt_cargo", "qt-cargo.py")
|
||||
smoke = load("qt_smoke", "qt-smoke.py")
|
||||
targets = load("qt_target", "qt-target.py")
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "unprivileged Windows symlinks may be unavailable")
|
||||
class TargetTests(unittest.TestCase):
|
||||
"""Each worktree keeps its own binaries when the host shares one target."""
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name).resolve()
|
||||
self.worktree = self.root / "checkouts" / "main-tree"
|
||||
self.crates = self.worktree / "crates"
|
||||
self.crates.mkdir(parents=True)
|
||||
self.manifest = self.crates / "Cargo.toml"
|
||||
self.manifest.touch()
|
||||
self.shared = self.root / "nvme" / "qbz"
|
||||
(self.shared / "release" / "deps").mkdir(parents=True)
|
||||
(self.shared / "release" / "incremental").mkdir()
|
||||
(self.shared / "release" / "deps" / "libdep.rlib").write_bytes(b"registry crate")
|
||||
(self.shared / "release" / "incremental" / "session").write_bytes(b"workspace cache")
|
||||
(self.shared / "release" / "qbz").write_bytes(b"another worktree's binary")
|
||||
self.derived = self.root / "nvme" / "qbz-worktrees" / "main-tree"
|
||||
|
||||
def test_shared_host_target_becomes_this_worktrees_own_sibling(self):
|
||||
self.assertEqual(targets.worktree_target(self.shared, self.worktree, {}), self.derived)
|
||||
other = self.root / "checkouts" / "codex-tree"
|
||||
self.assertEqual(targets.worktree_target(self.shared, other, {}),
|
||||
self.root / "nvme" / "qbz-worktrees" / "codex-tree")
|
||||
|
||||
def test_explicit_override_and_checkout_target_are_used_as_given(self):
|
||||
for name in targets.EXPLICIT_ENV:
|
||||
with self.subTest(env=name):
|
||||
self.assertEqual(targets.worktree_target(self.shared, self.worktree, {name: "/pinned"}),
|
||||
self.shared)
|
||||
inside = self.crates / "target"
|
||||
self.assertEqual(targets.worktree_target(inside, self.worktree, {}), inside)
|
||||
|
||||
def test_two_checkouts_with_one_directory_name_never_share_a_target(self):
|
||||
self.derived.mkdir(parents=True)
|
||||
(self.derived / targets.MARKER).write_text(str(self.root / "elsewhere" / "main-tree") + "\n")
|
||||
second = targets.worktree_target(self.shared, self.worktree, {})
|
||||
self.assertNotEqual(second, self.derived)
|
||||
self.assertTrue(second.name.startswith("main-tree-"))
|
||||
self.assertEqual(second, targets.worktree_target(self.shared, self.worktree, {}))
|
||||
(self.derived / targets.MARKER).write_text(str(self.worktree) + "\n")
|
||||
self.assertEqual(targets.worktree_target(self.shared, self.worktree, {}), self.derived)
|
||||
|
||||
def test_resolve_reads_cargo_metadata_for_the_manifest(self):
|
||||
metadata = {"target_directory": str(self.shared), "workspace_root": str(self.crates)}
|
||||
with patch.object(targets, "cargo_metadata", return_value=metadata) as cargo, \
|
||||
patch.object(targets, "worktree_root", return_value=self.worktree):
|
||||
self.assertEqual(targets.resolve_target(self.manifest, {}), self.derived)
|
||||
self.assertEqual(cargo.call_args.args[0], self.manifest)
|
||||
|
||||
def test_link_keeps_the_familiar_path_and_claims_the_worktree_target(self):
|
||||
(self.derived / "release").mkdir(parents=True)
|
||||
(self.derived / "release" / "qbz").write_bytes(b"this worktree's binary")
|
||||
targets.link_target(self.manifest, self.derived, self.worktree)
|
||||
targets.link_target(self.manifest, self.derived, self.worktree)
|
||||
alias = self.crates / "target"
|
||||
self.assertTrue(alias.is_symlink())
|
||||
self.assertEqual((alias / "release" / "qbz").read_bytes(), b"this worktree's binary")
|
||||
self.assertEqual((self.derived / targets.MARKER).read_text().strip(), str(self.worktree))
|
||||
# A pinned target is linked but never claimed.
|
||||
targets.link_target(self.manifest, self.shared, self.worktree)
|
||||
self.assertFalse((self.shared / targets.MARKER).exists())
|
||||
|
||||
def test_real_directory_is_never_removed(self):
|
||||
alias = self.crates / "target"
|
||||
alias.mkdir()
|
||||
sentinel = alias / "keep"
|
||||
sentinel.write_text("preserve")
|
||||
targets.link_target(self.manifest, alias)
|
||||
with self.assertRaisesRegex(RuntimeError, "preserving"):
|
||||
targets.link_target(self.manifest, self.shared)
|
||||
self.assertEqual(sentinel.read_text(), "preserve")
|
||||
self.assertFalse(alias.is_symlink())
|
||||
|
||||
def test_old_shortcut_is_replaced_but_a_missing_build_cannot_replace_it(self):
|
||||
alias = self.crates / "target"
|
||||
alias.symlink_to(self.root / "old", target_is_directory=True)
|
||||
with self.assertRaisesRegex(RuntimeError, "does not exist"):
|
||||
targets.link_target(self.manifest, self.root / "missing")
|
||||
self.assertEqual(os.readlink(alias), str(self.root / "old"))
|
||||
targets.link_target(self.manifest, self.shared)
|
||||
self.assertEqual(alias.resolve(), self.shared)
|
||||
|
||||
@staticmethod
|
||||
def copy(entries, destination):
|
||||
for entry in entries:
|
||||
if entry.is_dir():
|
||||
shutil.copytree(entry, destination / entry.name)
|
||||
else:
|
||||
shutil.copy2(entry, destination / entry.name)
|
||||
|
||||
def test_seed_clones_a_missing_profile_once_and_skips_incremental(self):
|
||||
self.assertEqual(targets.seed_target(self.derived, self.shared, ["release", "debug"], self.copy),
|
||||
["release"])
|
||||
self.assertEqual((self.derived / "release" / "deps" / "libdep.rlib").read_bytes(), b"registry crate")
|
||||
self.assertFalse((self.derived / "release" / "incremental").exists())
|
||||
(self.derived / "release" / "qbz").write_bytes(b"this worktree's binary")
|
||||
self.assertEqual(targets.seed_target(self.derived, self.shared, ["release"], self.copy), [])
|
||||
self.assertEqual((self.derived / "release" / "qbz").read_bytes(), b"this worktree's binary")
|
||||
self.assertEqual((self.shared / "release" / "qbz").read_bytes(), b"another worktree's binary")
|
||||
|
||||
def test_seed_never_touches_pinned_targets_and_leaves_no_partial_clone(self):
|
||||
pinned = self.root / "pinned"
|
||||
self.assertEqual(targets.seed_target(pinned, self.shared, ["release"], self.copy), [])
|
||||
self.assertFalse(pinned.exists())
|
||||
self.assertEqual(targets.seed_target(self.shared, self.shared, ["release"], self.copy), [])
|
||||
|
||||
def broken(entries, destination):
|
||||
(destination / "half").write_bytes(b"partial")
|
||||
raise OSError("clone not supported")
|
||||
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(targets.seed_target(self.derived, self.shared, ["release"], broken), [])
|
||||
self.assertEqual(list(self.derived.iterdir()), [])
|
||||
|
||||
|
||||
class CargoTargetEnvironmentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name).resolve()
|
||||
self.worktree = self.root / "tree"
|
||||
self.shared = self.root / "shared" / "qbz"
|
||||
self.metadata = {"target_directory": str(self.shared), "workspace_root": str(self.worktree / "crates")}
|
||||
for name, value in (("cargo_metadata", lambda *_: self.metadata),
|
||||
("worktree_root", lambda *_: self.worktree)):
|
||||
mock = patch.object(targets, name, side_effect=value)
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
|
||||
def environment(self, env, arguments=("--manifest-path", "crates/Cargo.toml")):
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
return qt.target_environment(env, list(arguments), targets)
|
||||
|
||||
def test_direct_builds_and_tests_use_the_worktree_target(self):
|
||||
env = self.environment({"PATH": "/bin"})
|
||||
self.assertEqual(env["CARGO_TARGET_DIR"], str(self.root / "shared" / "qbz-worktrees" / "tree"))
|
||||
self.assertEqual(targets.cargo_metadata.call_args.args[0], Path("crates/Cargo.toml"))
|
||||
|
||||
def test_explicit_and_checkout_targets_are_left_to_cargo(self):
|
||||
pinned = {"CARGO_TARGET_DIR": "/pinned"}
|
||||
self.assertIs(self.environment(pinned), pinned)
|
||||
self.metadata["target_directory"] = str(self.worktree / "crates" / "target")
|
||||
plain = {"PATH": "/bin"}
|
||||
self.assertIs(self.environment(plain), plain)
|
||||
|
||||
def test_unresolvable_metadata_keeps_cargos_own_choice(self):
|
||||
targets.cargo_metadata.side_effect = subprocess.CalledProcessError(101, "cargo")
|
||||
plain = {"PATH": "/bin"}
|
||||
self.assertIs(self.environment(plain), plain)
|
||||
|
||||
def test_manifest_argument_forms(self):
|
||||
self.assertEqual(qt.manifest_argument(["-p", "qbz-qt", "--manifest-path", "a/Cargo.toml"]),
|
||||
Path("a/Cargo.toml"))
|
||||
self.assertEqual(qt.manifest_argument(["--manifest-path=b/Cargo.toml"]), Path("b/Cargo.toml"))
|
||||
self.assertIsNone(qt.manifest_argument(["--release"]))
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "posix" and shutil.which("bash"), "requires bash")
|
||||
class RunnerTargetTests(unittest.TestCase):
|
||||
def test_runner_builds_in_the_worktree_target_links_after_success_and_runs_it(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory).resolve()
|
||||
root = base / "main tree"
|
||||
scripts = root / "scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
(root / "crates").mkdir()
|
||||
(root / "crates/Cargo.toml").touch()
|
||||
shared = base / "shared target"
|
||||
expected = base / "shared target-worktrees" / "main-tree"
|
||||
for filename in ("qt-run.sh", "qt-target.py"):
|
||||
shutil.copyfile(Path(__file__).with_name(filename), scripts / filename)
|
||||
fake_bin = base / "bin"
|
||||
fake_bin.mkdir()
|
||||
cargo = fake_bin / "cargo"
|
||||
cargo.write_text('#!/bin/sh\n[ "$FAIL_METADATA" = 1 ] && exit 7\nprintf \'%s\\n\' "$TARGET_METADATA"\n')
|
||||
cargo.chmod(0o755)
|
||||
git = fake_bin / "git"
|
||||
git.write_text("#!/bin/sh\nexit 128\n")
|
||||
git.chmod(0o755)
|
||||
(scripts / "qt-cargo.py").write_text(
|
||||
'import os, pathlib, sys\n'
|
||||
'target = pathlib.Path(os.environ["CARGO_TARGET_DIR"])\n'
|
||||
'assert target == pathlib.Path(os.environ["EXPECTED_TARGET"]), target\n'
|
||||
'if os.environ.get("FAIL_BUILD") == "1": sys.exit(9)\n'
|
||||
'(target / "release").mkdir(parents=True, exist_ok=True)\n'
|
||||
'binary = target / "release/qbz"\n'
|
||||
'binary.write_text("#!/bin/sh\\nprintf \'artifact executed: %s\\\\n\' \\"$1\\"\\n")\n'
|
||||
'binary.chmod(0o755)\n'
|
||||
)
|
||||
metadata = json.dumps({"target_directory": str(shared), "workspace_root": str(root / "crates")})
|
||||
env = dict(os.environ, PATH=str(fake_bin) + os.pathsep + os.environ["PATH"],
|
||||
TARGET_METADATA=metadata, EXPECTED_TARGET=str(expected), NO_AUDIT="1",
|
||||
NO_TICKER="1", FORCE="1", NORUN="0", SMOKE="0", TEST="0", DEBUG="0",
|
||||
MOLD="0", XDG_CACHE_HOME=str(base / "cache"), FAIL_METADATA="1", FAIL_BUILD="0")
|
||||
for name in targets.EXPLICIT_ENV:
|
||||
env.pop(name, None)
|
||||
command = ["bash", str(scripts / "qt-run.sh"), "fixture-argument"]
|
||||
failed = subprocess.run(command, env=env, text=True, capture_output=True)
|
||||
self.assertNotEqual(failed.returncode, 0)
|
||||
self.assertFalse(expected.exists())
|
||||
env.update(FAIL_METADATA="0", FAIL_BUILD="1")
|
||||
failed = subprocess.run(command, env=env, text=True, capture_output=True)
|
||||
self.assertEqual(failed.returncode, 9, failed.stderr)
|
||||
self.assertFalse((root / "crates/target").is_symlink())
|
||||
env["FAIL_BUILD"] = "0"
|
||||
result = subprocess.run(command, env=env, text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("artifact executed: fixture-argument", result.stdout)
|
||||
self.assertEqual((root / "crates/target/release/qbz").resolve(), expected / "release/qbz")
|
||||
self.assertFalse((shared / "release/qbz").exists(), "the shared target is never built into")
|
||||
|
||||
|
||||
class SdkTests(unittest.TestCase):
|
||||
|
||||
Reference in new issue
Block a user