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