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()
{
return cursor().shape();
@ -112,6 +125,8 @@ void DelegateTouchArea::mouseUngrabEvent()
{
if (m_pressed) {
handleReleaseEvent(nullptr, false);
} else {
setDragging(false);
}
QQuickItem::mouseUngrabEvent();
}
@ -151,6 +166,8 @@ void DelegateTouchArea::touchUngrabEvent()
{
if (m_pressed) {
handleReleaseEvent(nullptr, false);
} else {
setDragging(false);
}
QQuickItem::touchUngrabEvent();
}
@ -194,6 +211,7 @@ void DelegateTouchArea::handleReleaseEvent(QPointerEvent *event, bool click)
Q_UNUSED(event)
bool wasPressed = m_pressed;
setPressed(false);
setDragging(false);
if (!m_pressAndHeld && click && wasPressed) {
Q_EMIT clicked();
@ -213,6 +231,11 @@ void DelegateTouchArea::handleMoveEvent(QPointerEvent *event, QPointF point)
if (QPointF(point - m_mouseDownPosition).manhattanLength() >= QGuiApplication::styleHints()->startDragDistance()) {
m_pressAndHoldTimer->stop();
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 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(QPointF pressPosition READ pressPosition NOTIFY pressPositionChanged FINAL)
@ -30,6 +31,7 @@ public:
bool pressed();
bool hovered();
bool dragging();
Qt::CursorShape cursorShape();
void setCursorShape(Qt::CursorShape cursorShape);
void unsetCursor();
@ -40,6 +42,8 @@ Q_SIGNALS:
void rightMousePress();
void pressAndHold();
void pressAndHoldReleased();
void draggingChanged();
void dragMoved(qreal deltaX);
void pressedChanged(bool pressed);
void hoveredChanged(bool hovered);
void cursorShapeChanged();
@ -69,6 +73,7 @@ private:
bool m_pressed{false};
bool m_hovered{false};
bool m_dragging{false};
bool m_pressAndHeld{false};
Qt::CursorShape m_cursorShape{Qt::ArrowCursor};
QPointF m_mouseDownPosition{};

View file

@ -44,7 +44,7 @@ public:
Q_INVOKABLE void removeEntry(int row);
Q_INVOKABLE bool addApplication(const QString &storageId);
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 addEntry(int row, std::shared_ptr<FolioDelegate> delegate);
std::shared_ptr<FolioDelegate> getEntryAt(int row);

View file

@ -46,6 +46,15 @@ MouseArea {
// Thumbnail popup hover tracking
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)
Rectangle {
id: homeButton
@ -192,7 +201,25 @@ MouseArea {
// multiply the 'fromCenterValue' by the cell size to get the actual position
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
implicitWidth: root.dockCellWidth
@ -296,18 +323,47 @@ MouseArea {
// don't show label in drag and drop mode
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: {
// prevent editing if lock layout is enabled
if (folio.FolioSettings.lockLayout) return;
let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appDelegate.delegateItem);
folio.HomeScreenState.startDelegateFavouritesDrag(
mappedCoords.x,
mappedCoords.y,
appDelegate.pressPosition.x,
appDelegate.pressPosition.y,
delegate.index
);
// In convergence mode, drag-reorder is handled by DragHandler;
// only open the context menu on press-and-hold.
if (!root.convergenceMode) {
let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appDelegate.delegateItem);
folio.HomeScreenState.startDelegateFavouritesDrag(
mappedCoords.x,
mappedCoords.y,
appDelegate.pressPosition.x,
appDelegate.pressPosition.y,
delegate.index
);
}
contextMenu.open();
haptics.buttonVibrate();
@ -315,7 +371,7 @@ MouseArea {
onPressAndHoldReleased: {
// 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();
}
}
@ -380,15 +436,45 @@ MouseArea {
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: {
let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appFolderDelegate.delegateItem);
folio.HomeScreenState.startDelegateFavouritesDrag(
mappedCoords.x,
mappedCoords.y,
appFolderDelegate.pressPosition.x,
appFolderDelegate.pressPosition.y,
delegate.index
);
// prevent editing if lock layout is enabled
if (folio.FolioSettings.lockLayout) return;
if (!root.convergenceMode) {
let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appFolderDelegate.delegateItem);
folio.HomeScreenState.startDelegateFavouritesDrag(
mappedCoords.x,
mappedCoords.y,
appFolderDelegate.pressPosition.x,
appFolderDelegate.pressPosition.y,
delegate.index
);
}
contextMenu.open();
haptics.buttonVibrate();
@ -396,7 +482,7 @@ MouseArea {
onPressAndHoldReleased: {
// 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();
}
}