Add drag-reorder for dock favourites

Click-and-drag reordering of favourites bar items in convergence
mode.  The existing touch-based press-and-hold drag is kept for
mobile; in convergence mode, press-and-hold opens the context
menu only.

DelegateTouchArea owns the exclusive mouse grab at the C++ level,
so drag detection (threshold crossing + delta signals) is added
there rather than using a QML DragHandler.  Displaced items
animate into their new positions while the dragged item follows
the cursor.

Expose FavouritesModel::moveEntry as Q_INVOKABLE so QML can
persist the reorder.
This commit is contained in:
Marco Allegretti 2026-04-12 14:15:13 +02:00
parent ca6170c1f5
commit 9bccbeede6
4 changed files with 134 additions and 20 deletions

View file

@ -55,6 +55,19 @@ void DelegateTouchArea::setHovered(bool hovered)
} }
} }
bool DelegateTouchArea::dragging()
{
return m_dragging;
}
void DelegateTouchArea::setDragging(bool dragging)
{
if (dragging != m_dragging) {
m_dragging = dragging;
Q_EMIT draggingChanged();
}
}
Qt::CursorShape DelegateTouchArea::cursorShape() Qt::CursorShape DelegateTouchArea::cursorShape()
{ {
return cursor().shape(); return cursor().shape();
@ -112,6 +125,8 @@ void DelegateTouchArea::mouseUngrabEvent()
{ {
if (m_pressed) { if (m_pressed) {
handleReleaseEvent(nullptr, false); handleReleaseEvent(nullptr, false);
} else {
setDragging(false);
} }
QQuickItem::mouseUngrabEvent(); QQuickItem::mouseUngrabEvent();
} }
@ -151,6 +166,8 @@ void DelegateTouchArea::touchUngrabEvent()
{ {
if (m_pressed) { if (m_pressed) {
handleReleaseEvent(nullptr, false); handleReleaseEvent(nullptr, false);
} else {
setDragging(false);
} }
QQuickItem::touchUngrabEvent(); QQuickItem::touchUngrabEvent();
} }
@ -194,6 +211,7 @@ void DelegateTouchArea::handleReleaseEvent(QPointerEvent *event, bool click)
Q_UNUSED(event) Q_UNUSED(event)
bool wasPressed = m_pressed; bool wasPressed = m_pressed;
setPressed(false); setPressed(false);
setDragging(false);
if (!m_pressAndHeld && click && wasPressed) { if (!m_pressAndHeld && click && wasPressed) {
Q_EMIT clicked(); Q_EMIT clicked();
@ -213,6 +231,11 @@ void DelegateTouchArea::handleMoveEvent(QPointerEvent *event, QPointF point)
if (QPointF(point - m_mouseDownPosition).manhattanLength() >= QGuiApplication::styleHints()->startDragDistance()) { if (QPointF(point - m_mouseDownPosition).manhattanLength() >= QGuiApplication::styleHints()->startDragDistance()) {
m_pressAndHoldTimer->stop(); m_pressAndHoldTimer->stop();
setPressed(false); setPressed(false);
if (!m_pressAndHeld) {
setDragging(true);
Q_EMIT dragMoved(point.x() - m_mouseDownPosition.x());
}
} }
} }

View file

@ -20,6 +20,7 @@ class DelegateTouchArea : public QQuickItem
Q_PROPERTY(bool pressed READ pressed NOTIFY pressedChanged FINAL) Q_PROPERTY(bool pressed READ pressed NOTIFY pressedChanged FINAL)
Q_PROPERTY(bool hovered READ hovered NOTIFY hoveredChanged FINAL) Q_PROPERTY(bool hovered READ hovered NOTIFY hoveredChanged FINAL)
Q_PROPERTY(bool dragging READ dragging NOTIFY draggingChanged FINAL)
Q_PROPERTY(Qt::CursorShape cursorShape READ cursorShape WRITE setCursorShape RESET unsetCursor NOTIFY cursorShapeChanged FINAL) Q_PROPERTY(Qt::CursorShape cursorShape READ cursorShape WRITE setCursorShape RESET unsetCursor NOTIFY cursorShapeChanged FINAL)
Q_PROPERTY(QPointF pressPosition READ pressPosition NOTIFY pressPositionChanged FINAL) Q_PROPERTY(QPointF pressPosition READ pressPosition NOTIFY pressPositionChanged FINAL)
@ -30,6 +31,7 @@ public:
bool pressed(); bool pressed();
bool hovered(); bool hovered();
bool dragging();
Qt::CursorShape cursorShape(); Qt::CursorShape cursorShape();
void setCursorShape(Qt::CursorShape cursorShape); void setCursorShape(Qt::CursorShape cursorShape);
void unsetCursor(); void unsetCursor();
@ -40,6 +42,8 @@ Q_SIGNALS:
void rightMousePress(); void rightMousePress();
void pressAndHold(); void pressAndHold();
void pressAndHoldReleased(); void pressAndHoldReleased();
void draggingChanged();
void dragMoved(qreal deltaX);
void pressedChanged(bool pressed); void pressedChanged(bool pressed);
void hoveredChanged(bool hovered); void hoveredChanged(bool hovered);
void cursorShapeChanged(); void cursorShapeChanged();
@ -69,6 +73,7 @@ private:
bool m_pressed{false}; bool m_pressed{false};
bool m_hovered{false}; bool m_hovered{false};
bool m_dragging{false};
bool m_pressAndHeld{false}; bool m_pressAndHeld{false};
Qt::CursorShape m_cursorShape{Qt::ArrowCursor}; Qt::CursorShape m_cursorShape{Qt::ArrowCursor};
QPointF m_mouseDownPosition{}; QPointF m_mouseDownPosition{};

View file

@ -44,7 +44,7 @@ public:
Q_INVOKABLE void removeEntry(int row); Q_INVOKABLE void removeEntry(int row);
Q_INVOKABLE bool addApplication(const QString &storageId); Q_INVOKABLE bool addApplication(const QString &storageId);
Q_INVOKABLE bool containsApplication(const QString &storageId) const; Q_INVOKABLE bool containsApplication(const QString &storageId) const;
void moveEntry(int fromRow, int toRow); Q_INVOKABLE void moveEntry(int fromRow, int toRow);
bool canAddEntry(int row, std::shared_ptr<FolioDelegate> delegate); bool canAddEntry(int row, std::shared_ptr<FolioDelegate> delegate);
bool addEntry(int row, std::shared_ptr<FolioDelegate> delegate); bool addEntry(int row, std::shared_ptr<FolioDelegate> delegate);
std::shared_ptr<FolioDelegate> getEntryAt(int row); std::shared_ptr<FolioDelegate> getEntryAt(int row);

View file

@ -46,6 +46,15 @@ MouseArea {
// Thumbnail popup hover tracking // Thumbnail popup hover tracking
property int hoveredTaskIndex: -1 property int hoveredTaskIndex: -1
// Drag-reorder state (convergence mode only)
property int dragReorderIndex: -1
property real dragReorderOffset: 0
readonly property int dragTargetIndex: {
if (dragReorderIndex === -1) return -1
let shift = Math.round(dragReorderOffset / dockCellWidth)
return Math.max(0, Math.min(repeater.count - 1, dragReorderIndex + shift))
}
// Home button (convergence mode, left end) // Home button (convergence mode, left end)
Rectangle { Rectangle {
id: homeButton id: homeButton
@ -192,7 +201,25 @@ MouseArea {
// multiply the 'fromCenterValue' by the cell size to get the actual position // multiply the 'fromCenterValue' by the cell size to get the actual position
readonly property int centerPosition: (isLocationBottom ? root.dockCellWidth : root.dockCellHeight) * fromCenterValue readonly property int centerPosition: (isLocationBottom ? root.dockCellWidth : root.dockCellHeight) * fromCenterValue
x: isLocationBottom ? centerPosition + root.dockCenterX : (parent.width - width) / 2 // Visual shift during drag-reorder: dragged item follows cursor,
// displaced items slide to make room.
property real dragVisualShift: {
if (root.dragReorderIndex === -1) return 0
if (delegate.index === root.dragReorderIndex) return root.dragReorderOffset
let targetIdx = root.dragTargetIndex
let myIdx = delegate.index
let dragIdx = root.dragReorderIndex
let cellW = root.dockCellWidth
if (targetIdx > dragIdx && myIdx > dragIdx && myIdx <= targetIdx) return -cellW
if (targetIdx < dragIdx && myIdx >= targetIdx && myIdx < dragIdx) return cellW
return 0
}
Behavior on dragVisualShift {
enabled: root.dragReorderIndex !== -1 && delegate.index !== root.dragReorderIndex
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}
x: (isLocationBottom ? centerPosition + root.dockCenterX : (parent.width - width) / 2) + dragVisualShift
y: isLocationBottom ? (parent.height - height) / 2 : parent.height / 2 - centerPosition - root.dockCellHeight y: isLocationBottom ? (parent.height - height) / 2 : parent.height / 2 - centerPosition - root.dockCellHeight
implicitWidth: root.dockCellWidth implicitWidth: root.dockCellWidth
@ -296,18 +323,47 @@ MouseArea {
// don't show label in drag and drop mode // don't show label in drag and drop mode
labelOpacity: delegate.opacity labelOpacity: delegate.opacity
// Convergence drag-reorder: click-and-drag to reorder
onDraggingChanged: {
if (root.convergenceMode && !folio.FolioSettings.lockLayout) {
if (appDelegate.dragging) {
contextMenu.close()
root.dragReorderIndex = delegate.index
root.dragReorderOffset = 0
} else {
let from = root.dragReorderIndex
let to = root.dragTargetIndex
root.dragReorderIndex = -1
root.dragReorderOffset = 0
if (from !== -1 && to !== -1 && from !== to) {
folio.FavouritesModel.moveEntry(from, to)
}
}
}
}
onDragMoved: (deltaX) => {
if (root.convergenceMode && !folio.FolioSettings.lockLayout) {
root.dragReorderOffset = deltaX
}
}
onPressAndHold: { onPressAndHold: {
// prevent editing if lock layout is enabled // prevent editing if lock layout is enabled
if (folio.FolioSettings.lockLayout) return; if (folio.FolioSettings.lockLayout) return;
let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appDelegate.delegateItem); // In convergence mode, drag-reorder is handled by DragHandler;
folio.HomeScreenState.startDelegateFavouritesDrag( // only open the context menu on press-and-hold.
mappedCoords.x, if (!root.convergenceMode) {
mappedCoords.y, let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appDelegate.delegateItem);
appDelegate.pressPosition.x, folio.HomeScreenState.startDelegateFavouritesDrag(
appDelegate.pressPosition.y, mappedCoords.x,
delegate.index mappedCoords.y,
); appDelegate.pressPosition.x,
appDelegate.pressPosition.y,
delegate.index
);
}
contextMenu.open(); contextMenu.open();
haptics.buttonVibrate(); haptics.buttonVibrate();
@ -315,7 +371,7 @@ MouseArea {
onPressAndHoldReleased: { onPressAndHoldReleased: {
// cancel the event if the delegate is not dragged // cancel the event if the delegate is not dragged
if (folio.HomeScreenState.swipeState === Folio.HomeScreenState.AwaitingDraggingDelegate) { if (!root.convergenceMode && folio.HomeScreenState.swipeState === Folio.HomeScreenState.AwaitingDraggingDelegate) {
homeScreen.cancelDelegateDrag(); homeScreen.cancelDelegateDrag();
} }
} }
@ -380,15 +436,45 @@ MouseArea {
folio.HomeScreenState.openFolder(pos.x, pos.y, delegate.delegateModel.folder); folio.HomeScreenState.openFolder(pos.x, pos.y, delegate.delegateModel.folder);
} }
// Convergence drag-reorder: click-and-drag to reorder
onDraggingChanged: {
if (root.convergenceMode && !folio.FolioSettings.lockLayout) {
if (appFolderDelegate.dragging) {
contextMenu.close()
root.dragReorderIndex = delegate.index
root.dragReorderOffset = 0
} else {
let from = root.dragReorderIndex
let to = root.dragTargetIndex
root.dragReorderIndex = -1
root.dragReorderOffset = 0
if (from !== -1 && to !== -1 && from !== to) {
folio.FavouritesModel.moveEntry(from, to)
}
}
}
}
onDragMoved: (deltaX) => {
if (root.convergenceMode && !folio.FolioSettings.lockLayout) {
root.dragReorderOffset = deltaX
}
}
onPressAndHold: { onPressAndHold: {
let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appFolderDelegate.delegateItem); // prevent editing if lock layout is enabled
folio.HomeScreenState.startDelegateFavouritesDrag( if (folio.FolioSettings.lockLayout) return;
mappedCoords.x,
mappedCoords.y, if (!root.convergenceMode) {
appFolderDelegate.pressPosition.x, let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appFolderDelegate.delegateItem);
appFolderDelegate.pressPosition.y, folio.HomeScreenState.startDelegateFavouritesDrag(
delegate.index mappedCoords.x,
); mappedCoords.y,
appFolderDelegate.pressPosition.x,
appFolderDelegate.pressPosition.y,
delegate.index
);
}
contextMenu.open(); contextMenu.open();
haptics.buttonVibrate(); haptics.buttonVibrate();
@ -396,7 +482,7 @@ MouseArea {
onPressAndHoldReleased: { onPressAndHoldReleased: {
// cancel the event if the delegate is not dragged // cancel the event if the delegate is not dragged
if (folio.HomeScreenState.swipeState === Folio.HomeScreenState.AwaitingDraggingDelegate) { if (!root.convergenceMode && folio.HomeScreenState.swipeState === Folio.HomeScreenState.AwaitingDraggingDelegate) {
root.homeScreen.cancelDelegateDrag(); root.homeScreen.cancelDelegateDrag();
} }
} }