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 /// and consumes the shared drag coordinates; all existing rows inherit
/// `showReorder` and therefore keep their current gesture policy. /// `showReorder` and therefore keep their current gesture policy.
property bool reorderDrag: root.showReorder 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 /// 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 /// 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. /// first row's ↑ and the last row's ↓ are drawn disabled instead.
@@ -1535,7 +1539,8 @@ Rectangle {
root.dragging = true root.dragging = true
root.bodyDragStarted(root.number) root.bodyDragStarted(root.number)
QbzShell.dragStart(root.item.id, root.item.title || "", 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) 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 // the sidebar: "N tracks" for a group, or title + "artist · album" for
// one. Visual only (never blocks the pointer). // one. Visual only (never blocks the pointer).
Rectangle { Rectangle {
visible: QbzShell.dragActive visible: QbzShell.dragActive && !QbzShell.dragInlineVisual
x: QbzShell.dragX + 10 x: QbzShell.dragX + 10
y: QbzShell.dragY + 14 y: QbzShell.dragY + 14
width: ghostCol.width + 28 width: ghostCol.width + 28
+114 -11
View File
@@ -39,7 +39,10 @@ Rectangle {
QbzQueue.queueExtendedOpened() QbzQueue.queueExtendedOpened()
root.scheduleVisibleCovers() root.scheduleVisibleCovers()
} }
Component.onDestruction: QbzQueue.queueExtendedClosed() Component.onDestruction: {
QbzShell.dragInlineVisual = false
QbzQueue.queueExtendedClosed()
}
onDocChanged: root.scheduleVisibleCovers() onDocChanged: root.scheduleVisibleCovers()
// ----------------------------- artwork ------------------------------ // ----------------------------- artwork ------------------------------
@@ -205,36 +208,63 @@ Rectangle {
QbzPlaylistPicker.openForTrack(row.id) 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 ---------------------------- // -------------------------- local reorder ----------------------------
property string dragPhase: "" property string dragPhase: ""
property int dragPhaseIndex: -1 property int dragPhaseIndex: -1
property string dragTrackId: "" property string dragTrackId: ""
property var dragRow: null
property int dragRowNumber: 0
property int dropSlot: -1 property int dropSlot: -1
property real dropLineY: -1 property real dropLineY: -1
property bool dropHot: false 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.dragPhase = row.phase
root.dragPhaseIndex = row.phaseIndex root.dragPhaseIndex = row.phaseIndex
root.dragTrackId = row.id root.dragTrackId = row.id
root.dragRow = row
root.dragRowNumber = visualIndex + 1
root.dropSlot = -1 root.dropSlot = -1
root.dropHot = false root.dropHot = false
} }
function clearInlineDrop() {
root.dropHot = false
root.dropSlot = -1
QbzShell.dragInlineVisual = false
}
function recomputeDrop() { function recomputeDrop() {
if (!QbzShell.dragActive || root.dragTrackId === "" || root.searchActive) { if (!QbzShell.dragActive || root.dragTrackId === "" || root.searchActive) {
root.dropHot = false root.clearInlineDrop()
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
return return
} }
var contentPoint = queueList.mapFromItem(null, QbzShell.dragX, QbzShell.dragY) 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 cy = queueList.contentY + contentPoint.y
var index = queueList.indexAt(Math.max(1, queueList.width / 2), cy) var index = queueList.indexAt(Math.max(1, queueList.width / 2), cy)
var insertion var insertion
@@ -248,6 +278,7 @@ Rectangle {
root.dropSlot = Math.max(0, Math.min(root.doc.upcomingCount || 0, root.dropSlot = Math.max(0, Math.min(root.doc.upcomingCount || 0,
insertion - root.firstUpcoming)) insertion - root.firstUpcoming))
root.dropHot = true root.dropHot = true
QbzShell.dragInlineVisual = true
var fullIndex = root.firstUpcoming + root.dropSlot var fullIndex = root.firstUpcoming + root.dropSlot
var lineItem = fullIndex < root.rows.length ? queueList.itemAtIndex(fullIndex) : null var lineItem = fullIndex < root.rows.length ? queueList.itemAtIndex(fullIndex) : null
@@ -270,12 +301,21 @@ Rectangle {
root.dragPhase = "" root.dragPhase = ""
root.dragPhaseIndex = -1 root.dragPhaseIndex = -1
root.dragTrackId = "" root.dragTrackId = ""
root.dragRow = null
root.dragRowNumber = 0
root.dropSlot = -1 root.dropSlot = -1
root.dropHot = false root.dropHot = false
QbzShell.dragInlineVisual = false
if (shouldCommit) if (shouldCommit)
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId, slot) 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 { Connections {
target: QbzShell target: QbzShell
function onDragXChanged() { root.recomputeDrop() } function onDragXChanged() { root.recomputeDrop() }
@@ -538,6 +578,9 @@ Rectangle {
readonly property var displayItem: Object.assign({}, modelData, { readonly property var displayItem: Object.assign({}, modelData, {
"artPath": root.coverPath(modelData) "artPath": root.coverPath(modelData)
}) })
readonly property bool isDragSource: root.dragTrackId !== ""
&& root.dragPhase === modelData.phase
&& root.dragPhaseIndex === modelData.phaseIndex
width: queueList.width width: queueList.width
height: (heading !== "" ? 24 : 0) + 50 height: (heading !== "" ? 24 : 0) + 50
@@ -573,10 +616,17 @@ Rectangle {
showFavorite: false showFavorite: false
showDownload: false showDownload: false
showMenu: true 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 zebra: true
artistLink: true artistLink: true
draggable: root.dragAllowed(rowHost.modelData) draggable: root.dragAllowed(rowHost.modelData)
reorderDrag: true reorderDrag: true
inlineDragVisual: true
playBlocked: root.rowBlocked(rowHost.modelData) playBlocked: root.rowBlocked(rowHost.modelData)
activeBackground: true activeBackground: true
overrideActiveMatch: true overrideActiveMatch: true
@@ -588,9 +638,14 @@ Rectangle {
&& root.coverPath(rowHost.modelData) === "" && root.coverPath(rowHost.modelData) === ""
skelPhase: (Math.floor(Math.abs(QbzShell.pulseMs) / 900) % 2) === 1 skelPhase: (Math.floor(Math.abs(QbzShell.pulseMs) / 900) % 2) === 1
artSettleMs: 2500 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) 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) { onMenuActionRequested: function (action) {
root.menuAction(rowHost.modelData, action) root.menuAction(rowHost.modelData, action)
} }
@@ -606,8 +661,56 @@ Rectangle {
target: queueList 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 { Rectangle {
visible: root.dropHot && root.dropLineY >= 0 visible: root.dropHot && root.dropLineY >= 0
z: 30
x: queueList.x + theme.spacingSm x: queueList.x + theme.spacingSm
y: Math.max(0, Math.min(parent.height - height, root.dropLineY)) y: Math.max(0, Math.min(parent.height - height, root.dropLineY))
width: queueList.width - 2 * theme.spacingSm 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. /// is — `drag_end` runs off the Qt thread and must not read the bridge.
static DRAG_OVER_QUEUE: Mutex<i32> = Mutex::new(-1); 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); let id = track_id.parse::<u64>().unwrap_or(0);
*DRAGGED.lock().unwrap() = if id > 0 { vec![id] } else { Vec::new() }; *DRAGGED.lock().unwrap() = if id > 0 { vec![id] } else { Vec::new() };
log::info!("[qbz-qt][drag] start {track_id} ({title})"); 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())); .set_drag_subtitle(QString::from(subtitle.as_str()));
b.as_mut().set_drag_x(x); b.as_mut().set_drag_x(x);
b.as_mut().set_drag_y(y); 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_playlist_id(QString::default());
b.as_mut().set_drag_over_queue_index(-1); b.as_mut().set_drag_over_queue_index(-1);
b.as_mut().set_drag_active(true); 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); let queue_slot = std::mem::replace(&mut *DRAG_OVER_QUEUE.lock().unwrap(), -1);
shell_bridge::ui(|mut b| { shell_bridge::ui(|mut b| {
b.as_mut().set_drag_active(false); 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_playlist_id(QString::default());
b.as_mut().set_drag_over_queue_index(-1); 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(QString, drag_subtitle)]
#[qproperty(f32, drag_x)] #[qproperty(f32, drag_x)]
#[qproperty(f32, drag_y)] #[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)] #[qproperty(QString, drag_over_playlist_id)]
// The QUEUE drop target: the upcoming-list SLOT the dragged row would // 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 // be inserted at, or -1 when the pointer is not over the queue. Two
@@ -684,6 +689,7 @@ pub mod qbz_shell {
subtitle: QString, subtitle: QString,
x: f32, x: f32,
y: f32, y: f32,
inline_visual: bool,
); );
#[qinvokable] #[qinvokable]
fn drag_move(self: Pin<&mut QbzShell>, x: f32, y: f32); fn drag_move(self: Pin<&mut QbzShell>, x: f32, y: f32);
@@ -815,6 +821,7 @@ pub struct QbzShellRust {
drag_subtitle: QString, drag_subtitle: QString,
drag_x: f32, drag_x: f32,
drag_y: f32, drag_y: f32,
drag_inline_visual: bool,
drag_over_playlist_id: QString, drag_over_playlist_id: QString,
drag_over_queue_index: i32, drag_over_queue_index: i32,
filter_tip_json: QString, filter_tip_json: QString,
@@ -930,6 +937,7 @@ impl Default for QbzShellRust {
drag_subtitle: QString::default(), drag_subtitle: QString::default(),
drag_x: 0.0, drag_x: 0.0,
drag_y: 0.0, drag_y: 0.0,
drag_inline_visual: false,
drag_over_playlist_id: QString::default(), drag_over_playlist_id: QString::default(),
// -1 = the pointer is not over the queue. NOT 0, which is a valid // -1 = the pointer is not over the queue. NOT 0, which is a valid
// slot (drop at the very top of upcoming). // slot (drop at the very top of upcoming).
@@ -1341,6 +1349,7 @@ impl qbz_shell::QbzShell {
subtitle: QString, subtitle: QString,
x: f32, x: f32,
y: f32, y: f32,
inline_visual: bool,
) { ) {
crate::drag_start( crate::drag_start(
track_id.to_string(), track_id.to_string(),
@@ -1348,6 +1357,7 @@ impl qbz_shell::QbzShell {
subtitle.to_string(), subtitle.to_string(),
x, x,
y, y,
inline_visual,
); );
} }