fix(qbz-qt): make listen list reordering tangible

cx
This commit is contained in:
vicrodh committed 2026-08-26 02:12:49 -06:00
1 parent fdc77b0c93
commit 272d85fcd6
5 files changed
+141 -14

No files matched your search

+6 -1
View File
@@ -124,6 +124,10 @@ Rectangle {
/// and consumes the shared drag coordinates; all existing rows inherit
/// `showReorder` and therefore keep their current gesture policy.
property bool reorderDrag: root.showReorder
/// QueueView replaces the compact window-level drag pill with a complete
/// TrackRow while the pointer remains over its list. Other surfaces keep
/// the portable pill because their target may live outside the view.
property bool inlineDragVisual: false
/// Ends of the list: the reference dims nothing, but a chevron that
/// renders and no-ops is the defect class this round is fixing, so the
/// first row's ↑ and the last row's ↓ are drawn disabled instead.
@@ -1535,7 +1539,8 @@ Rectangle {
root.dragging = true
root.bodyDragStarted(root.number)
QbzShell.dragStart(root.item.id, root.item.title || "",
(root.item.artist || "") + " · " + (root.item.album || ""), g.x, g.y)
(root.item.artist || "") + " · " + (root.item.album || ""),
g.x, g.y, root.inlineDragVisual)
}
if (root.dragging) QbzShell.dragMove(g.x, g.y)
}
+1 -1
View File
@@ -638,7 +638,7 @@ Rectangle {
// the sidebar: "N tracks" for a group, or title + "artist · album" for
// one. Visual only (never blocks the pointer).
Rectangle {
visible: QbzShell.dragActive
visible: QbzShell.dragActive && !QbzShell.dragInlineVisual
x: QbzShell.dragX + 10
y: QbzShell.dragY + 14
width: ghostCol.width + 28
+114 -11
View File
@@ -39,7 +39,10 @@ Rectangle {
QbzQueue.queueExtendedOpened()
root.scheduleVisibleCovers()
}
Component.onDestruction: QbzQueue.queueExtendedClosed()
Component.onDestruction: {
QbzShell.dragInlineVisual = false
QbzQueue.queueExtendedClosed()
}
onDocChanged: root.scheduleVisibleCovers()
// ----------------------------- artwork ------------------------------
@@ -205,36 +208,63 @@ Rectangle {
QbzPlaylistPicker.openForTrack(row.id)
}
// The chevrons and the pointer drop share the same queue-wide insertion
// contract. Moving down inserts after the following row (`from + 2`),
// because `queueExtendedDrop` consumes a SLOT rather than a destination
// row index.
function moveQueueRow(row, delta) {
if (!row || row.phase !== "upcoming" || root.searchActive
|| root.rowBlocked(row))
return
var from = row.phaseIndex
var count = root.doc.upcomingCount || 0
if ((delta < 0 && from <= 0) || (delta > 0 && from >= count - 1))
return
var slot = delta < 0 ? from - 1 : from + 2
QbzQueue.queueExtendedDrop("upcoming", from, row.id, slot)
}
// -------------------------- local reorder ----------------------------
property string dragPhase: ""
property int dragPhaseIndex: -1
property string dragTrackId: ""
property var dragRow: null
property int dragRowNumber: 0
property int dropSlot: -1
property real dropLineY: -1
property bool dropHot: false
readonly property var dragDisplayItem: root.dragRow
? Object.assign({}, root.dragRow, { "artPath": root.coverPath(root.dragRow) })
: ({})
function beginQueueDrag(row) {
function beginQueueDrag(row, visualIndex) {
root.dragPhase = row.phase
root.dragPhaseIndex = row.phaseIndex
root.dragTrackId = row.id
root.dragRow = row
root.dragRowNumber = visualIndex + 1
root.dropSlot = -1
root.dropHot = false
}
function clearInlineDrop() {
root.dropHot = false
root.dropSlot = -1
QbzShell.dragInlineVisual = false
}
function recomputeDrop() {
if (!QbzShell.dragActive || root.dragTrackId === "" || root.searchActive) {
root.dropHot = false
root.dropSlot = -1
return
}
var p = listHost.mapFromItem(null, QbzShell.dragX, QbzShell.dragY)
if (p.x < 0 || p.x > queueList.width || p.y < 0 || p.y > listHost.height) {
root.dropHot = false
root.dropSlot = -1
root.clearInlineDrop()
return
}
var contentPoint = queueList.mapFromItem(null, QbzShell.dragX, QbzShell.dragY)
if (contentPoint.x < 0 || contentPoint.x > queueList.width
|| contentPoint.y < 0 || contentPoint.y > queueList.height) {
root.clearInlineDrop()
return
}
var cy = queueList.contentY + contentPoint.y
var index = queueList.indexAt(Math.max(1, queueList.width / 2), cy)
var insertion
@@ -248,6 +278,7 @@ Rectangle {
root.dropSlot = Math.max(0, Math.min(root.doc.upcomingCount || 0,
insertion - root.firstUpcoming))
root.dropHot = true
QbzShell.dragInlineVisual = true
var fullIndex = root.firstUpcoming + root.dropSlot
var lineItem = fullIndex < root.rows.length ? queueList.itemAtIndex(fullIndex) : null
@@ -270,12 +301,21 @@ Rectangle {
root.dragPhase = ""
root.dragPhaseIndex = -1
root.dragTrackId = ""
root.dragRow = null
root.dragRowNumber = 0
root.dropSlot = -1
root.dropHot = false
QbzShell.dragInlineVisual = false
if (shouldCommit)
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId, slot)
}
function dragProxyY() {
var p = listHost.mapFromItem(null, QbzShell.dragX, QbzShell.dragY)
return Math.max(queueList.y, Math.min(queueList.y + queueList.height - 50,
p.y - 25))
}
Connections {
target: QbzShell
function onDragXChanged() { root.recomputeDrop() }
@@ -538,6 +578,9 @@ Rectangle {
readonly property var displayItem: Object.assign({}, modelData, {
"artPath": root.coverPath(modelData)
})
readonly property bool isDragSource: root.dragTrackId !== ""
&& root.dragPhase === modelData.phase
&& root.dragPhaseIndex === modelData.phaseIndex
width: queueList.width
height: (heading !== "" ? 24 : 0) + 50
@@ -573,10 +616,17 @@ Rectangle {
showFavorite: false
showDownload: false
showMenu: true
showReorder: !root.searchActive
&& rowHost.modelData.phase === "upcoming"
&& !root.rowBlocked(rowHost.modelData)
canMoveUp: rowHost.modelData.phaseIndex > 0
canMoveDown: rowHost.modelData.phaseIndex
< (root.doc.upcomingCount || 0) - 1
zebra: true
artistLink: true
draggable: root.dragAllowed(rowHost.modelData)
reorderDrag: true
inlineDragVisual: true
playBlocked: root.rowBlocked(rowHost.modelData)
activeBackground: true
overrideActiveMatch: true
@@ -588,9 +638,14 @@ Rectangle {
&& root.coverPath(rowHost.modelData) === ""
skelPhase: (Math.floor(Math.abs(QbzShell.pulseMs) / 900) % 2) === 1
artSettleMs: 2500
// Keep the MouseArea alive until release while the
// complete visual row is carried by `dragProxy`.
opacity: rowHost.isDragSource ? 0 : 1
onPlayRequested: root.rowPlay(rowHost.modelData)
onBodyDragStarted: root.beginQueueDrag(rowHost.modelData)
onMoveUpRequested: root.moveQueueRow(rowHost.modelData, -1)
onMoveDownRequested: root.moveQueueRow(rowHost.modelData, 1)
onBodyDragStarted: root.beginQueueDrag(rowHost.modelData, rowHost.index)
onMenuActionRequested: function (action) {
root.menuAction(rowHost.modelData, action)
}
@@ -606,8 +661,56 @@ Rectangle {
target: queueList
}
// QueueView's drag visual is the row itself, not the generic
// title/subtitle pill. The source delegate stays alive but fully
// transparent so it retains the mouse grab; this identical row
// follows the pointer over the list. Leaving the list hides it and
// re-enables AppShell's compact ghost for sidebar playlist drops.
TrackRow {
id: dragProxy
visible: QbzShell.dragActive && root.dropHot && root.dragRow !== null
enabled: false
z: 20
x: queueList.x
y: root.dragProxyY()
width: queueList.width
item: root.dragDisplayItem
number: root.dragRowNumber
showArtwork: true
showAlbum: root.width >= 720
showFavorite: false
showDownload: false
showMenu: true
showReorder: root.dragRow !== null
&& root.dragRow.phase === "upcoming"
&& !root.searchActive
&& !root.rowBlocked(root.dragRow)
canMoveUp: root.dragRow !== null && root.dragRow.phaseIndex > 0
canMoveDown: root.dragRow !== null && root.dragRow.phaseIndex
< (root.doc.upcomingCount || 0) - 1
zebra: true
artistLink: true
draggable: false
reorderDrag: false
playBlocked: root.dragRow !== null && root.rowBlocked(root.dragRow)
activeBackground: true
overrideActiveMatch: true
activeMatch: root.dragRow !== null && root.dragRow.phase === "current"
leadingMarkerIcon: root.dragRow !== null
&& root.dragRow.id === (root.doc.stopAfterId || "")
? "circle-stop" : ""
menuEntriesOverride: root.dragRow !== null
? root.queueMenu(root.dragRow) : []
artPending: root.dragRow !== null
&& (root.dragRow.artUrl || "") !== ""
&& root.coverPath(root.dragRow) === ""
skelPhase: (Math.floor(Math.abs(QbzShell.pulseMs) / 900) % 2) === 1
artSettleMs: 2500
}
Rectangle {
visible: root.dropHot && root.dropLineY >= 0
z: 30
x: queueList.x + theme.spacingSm
y: Math.max(0, Math.min(parent.height - height, root.dropLineY))
width: queueList.width - 2 * theme.spacingSm
+10 -1
View File
@@ -1901,7 +1901,14 @@ static DRAG_OVER: Mutex<String> = Mutex::new(String::new());
/// is — `drag_end` runs off the Qt thread and must not read the bridge.
static DRAG_OVER_QUEUE: Mutex<i32> = Mutex::new(-1);
pub(crate) fn drag_start(track_id: String, title: String, subtitle: String, x: f32, y: f32) {
pub(crate) fn drag_start(
track_id: String,
title: String,
subtitle: String,
x: f32,
y: f32,
inline_visual: bool,
) {
let id = track_id.parse::<u64>().unwrap_or(0);
*DRAGGED.lock().unwrap() = if id > 0 { vec![id] } else { Vec::new() };
log::info!("[qbz-qt][drag] start {track_id} ({title})");
@@ -1912,6 +1919,7 @@ pub(crate) fn drag_start(track_id: String, title: String, subtitle: String, x: f
.set_drag_subtitle(QString::from(subtitle.as_str()));
b.as_mut().set_drag_x(x);
b.as_mut().set_drag_y(y);
b.as_mut().set_drag_inline_visual(inline_visual);
b.as_mut().set_drag_over_playlist_id(QString::default());
b.as_mut().set_drag_over_queue_index(-1);
b.as_mut().set_drag_active(true);
@@ -1945,6 +1953,7 @@ pub(crate) fn drag_end() {
let queue_slot = std::mem::replace(&mut *DRAG_OVER_QUEUE.lock().unwrap(), -1);
shell_bridge::ui(|mut b| {
b.as_mut().set_drag_active(false);
b.as_mut().set_drag_inline_visual(false);
b.as_mut().set_drag_over_playlist_id(QString::default());
b.as_mut().set_drag_over_queue_index(-1);
});
+10
View File
@@ -379,6 +379,11 @@ pub mod qbz_shell {
#[qproperty(QString, drag_subtitle)]
#[qproperty(f32, drag_x)]
#[qproperty(f32, drag_y)]
// True while the drag source supplies a full-size inline visual. The
// window-level compact pill stands down in that region; if the row
// leaves it (for example toward a sidebar playlist), the source flips
// this off and the portable ghost resumes.
#[qproperty(bool, drag_inline_visual)]
#[qproperty(QString, drag_over_playlist_id)]
// The QUEUE drop target: the upcoming-list SLOT the dragged row would
// be inserted at, or -1 when the pointer is not over the queue. Two
@@ -684,6 +689,7 @@ pub mod qbz_shell {
subtitle: QString,
x: f32,
y: f32,
inline_visual: bool,
);
#[qinvokable]
fn drag_move(self: Pin<&mut QbzShell>, x: f32, y: f32);
@@ -815,6 +821,7 @@ pub struct QbzShellRust {
drag_subtitle: QString,
drag_x: f32,
drag_y: f32,
drag_inline_visual: bool,
drag_over_playlist_id: QString,
drag_over_queue_index: i32,
filter_tip_json: QString,
@@ -930,6 +937,7 @@ impl Default for QbzShellRust {
drag_subtitle: QString::default(),
drag_x: 0.0,
drag_y: 0.0,
drag_inline_visual: false,
drag_over_playlist_id: QString::default(),
// -1 = the pointer is not over the queue. NOT 0, which is a valid
// slot (drop at the very top of upcoming).
@@ -1341,6 +1349,7 @@ impl qbz_shell::QbzShell {
subtitle: QString,
x: f32,
y: f32,
inline_visual: bool,
) {
crate::drag_start(
track_id.to_string(),
@@ -1348,6 +1357,7 @@ impl qbz_shell::QbzShell {
subtitle.to_string(),
x,
y,
inline_visual,
);
}