From eaa6a664c89f776f406d74d29eb8f7d1b4411e3d Mon Sep 17 00:00:00 2001 From: grokXcopilot <187270215+grokXcopilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:58:05 +0200 Subject: [PATCH] feat(niri): Add drag-and-drop workspace reordering (#1569) * feat(niri): Add drag-and-drop workspace reordering Add interactive drag-and-drop reordering for Niri workspace indicators with smooth animations matching the system tray behavior. - Add moveWorkspaceToIndex() to NiriService for workspace reordering - Implement drag detection with 5px threshold - Add shift animation for items between source and target - Clamp drag offset to stay within workspace row bounds - Reset drag state when workspace list changes during drag - Visual feedback: opacity change, border highlight on drag/drop target Co-Authored-By: Claude Opus 4.5 * feat(settings): Add workspace drag reorder toggle Add workspaceDragReorder setting to enable/disable workspace drag-and-drop reordering. Enabled by default, only visible on Niri. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- quickshell/Common/SettingsData.qml | 1 + quickshell/Common/settings/SettingsSpec.js | 1 + .../DankBar/Widgets/WorkspaceSwitcher.qml | 205 +++++++++++++++--- quickshell/Modules/Settings/WorkspacesTab.qml | 10 + quickshell/Services/NiriService.qml | 13 ++ 5 files changed, 200 insertions(+), 30 deletions(-) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 401b24f1..5011a6a8 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -241,6 +241,7 @@ Singleton { property bool showWorkspacePadding: false property bool workspaceScrolling: false property bool showWorkspaceApps: false + property bool workspaceDragReorder: true property bool groupWorkspaceApps: true property int maxWorkspaceIcons: 3 property int workspaceAppIconSizeOffset: 0 diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 80e82abf..620ddb31 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -98,6 +98,7 @@ var SPEC = { showWorkspacePadding: { def: false }, workspaceScrolling: { def: false }, showWorkspaceApps: { def: false }, + workspaceDragReorder: { def: true }, maxWorkspaceIcons: { def: 3 }, workspaceAppIconSizeOffset: { def: 0 }, groupWorkspaceApps: { def: true }, diff --git a/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml index 3eb1d4d3..f6ee3f05 100644 --- a/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml +++ b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml @@ -789,6 +789,18 @@ Item { } } + property int dragSourceIndex: -1 + property int dragTargetIndex: -1 + property bool suppressShiftAnimation: false + + onWorkspaceListChanged: { + if (dragSourceIndex >= 0) { + dragSourceIndex = -1; + dragTargetIndex = -1; + suppressShiftAnimation = false; + } + } + Flow { id: workspaceRow @@ -798,6 +810,7 @@ Item { flow: isVertical ? Flow.TopToBottom : Flow.LeftToRight Repeater { + id: workspaceRepeater model: ScriptModel { values: root.workspaceList } @@ -805,6 +818,44 @@ Item { Item { id: delegateRoot + property bool isDropTarget: root.dragTargetIndex === index + + z: dragHandler.dragging ? 1000 : 1 + + property real shiftOffset: { + if (root.dragSourceIndex < 0 || index === root.dragSourceIndex) + return 0; + const dragIdx = root.dragSourceIndex; + const dropIdx = root.dragTargetIndex; + if (dropIdx < 0) + return 0; + const shiftAmount = delegateRoot.width + Theme.spacingS; + if (dragIdx < dropIdx && index > dragIdx && index <= dropIdx) + return -shiftAmount; + if (dragIdx > dropIdx && index >= dropIdx && index < dragIdx) + return shiftAmount; + return 0; + } + + transform: Translate { + x: root.isVertical ? 0 : delegateRoot.shiftOffset + y: root.isVertical ? delegateRoot.shiftOffset : 0 + Behavior on x { + enabled: !root.suppressShiftAnimation + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + Behavior on y { + enabled: !root.suppressShiftAnimation + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + } + property bool isActive: { if (root.useExtWorkspace) return (modelData?.id || modelData?.name) === root.currentWorkspace; @@ -1031,45 +1082,126 @@ Item { readonly property color quickshellIconActiveColor: getContrastingIconColor(activeColor) readonly property color quickshellIconInactiveColor: getContrastingIconColor(unfocusedColor) + Item { + id: dragHandler + anchors.fill: parent + property bool dragging: false + property point dragStartPos: Qt.point(0, 0) + property real dragAxisOffset: 0 + + Connections { + target: root + function onWorkspaceListChanged() { + if (dragHandler.dragging) { + dragHandler.dragging = false; + dragHandler.dragAxisOffset = 0; + mouseArea.mousePressed = false; + } + } + } + } + MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: !isPlaceholder - cursorShape: isPlaceholder ? Qt.ArrowCursor : Qt.PointingHandCursor + cursorShape: isPlaceholder ? Qt.ArrowCursor : (dragHandler.dragging ? Qt.ClosedHandCursor : Qt.PointingHandCursor) enabled: !isPlaceholder acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: mouse => { - if (isPlaceholder) - return; - const isRightClick = mouse.button === Qt.RightButton; - if (root.useExtWorkspace && (modelData?.id || modelData?.name)) { - ExtWorkspaceService.activateWorkspace(modelData.id || modelData.name, modelData.groupID || ""); - } else if (CompositorService.isNiri) { - if (isRightClick) { - NiriService.toggleOverview(); - } else if (modelData && modelData.idx !== undefined) { - NiriService.switchToWorkspace(modelData.idx); + property bool mousePressed: false + + onPressed: mouse => { + if (mouse.button === Qt.LeftButton && CompositorService.isNiri && SettingsData.workspaceDragReorder && !isPlaceholder) { + mousePressed = true; + dragHandler.dragStartPos = Qt.point(mouse.x, mouse.y); + } + } + + onPositionChanged: mouse => { + if (!mousePressed || !CompositorService.isNiri || !SettingsData.workspaceDragReorder || isPlaceholder) + return; + + if (!dragHandler.dragging) { + const distance = root.isVertical + ? Math.abs(mouse.y - dragHandler.dragStartPos.y) + : Math.abs(mouse.x - dragHandler.dragStartPos.x); + if (distance > 5) { + dragHandler.dragging = true; + root.dragSourceIndex = index; + root.dragTargetIndex = index; } - } else if (CompositorService.isHyprland && modelData?.id) { - if (isRightClick && root.hyprlandOverviewLoader?.item) { - root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen; - } else { + } + + if (!dragHandler.dragging) + return; + + const rawAxisOffset = root.isVertical + ? (mouse.y - dragHandler.dragStartPos.y) + : (mouse.x - dragHandler.dragStartPos.x); + + const itemSize = (root.isVertical ? delegateRoot.height : delegateRoot.width) + Theme.spacingS; + const maxOffsetPositive = (root.workspaceList.length - 1 - index) * itemSize; + const maxOffsetNegative = -index * itemSize; + const axisOffset = Math.max(maxOffsetNegative, Math.min(maxOffsetPositive, rawAxisOffset)); + dragHandler.dragAxisOffset = axisOffset; + + const slotOffset = Math.round(axisOffset / itemSize); + const newTargetIndex = Math.max(0, Math.min(root.workspaceList.length - 1, index + slotOffset)); + + if (newTargetIndex !== root.dragTargetIndex) { + root.dragTargetIndex = newTargetIndex; + } + } + + onReleased: mouse => { + const wasDragging = dragHandler.dragging; + const didReorder = wasDragging && root.dragTargetIndex >= 0 && root.dragTargetIndex !== root.dragSourceIndex; + + if (didReorder) { + const sourceWs = root.workspaceList[root.dragSourceIndex]; + const targetWs = root.workspaceList[root.dragTargetIndex]; + + if (sourceWs && targetWs && sourceWs.idx !== undefined && targetWs.idx !== undefined) { + root.suppressShiftAnimation = true; + NiriService.moveWorkspaceToIndex(sourceWs.idx, targetWs.idx); + Qt.callLater(() => root.suppressShiftAnimation = false); + } + } + + mousePressed = false; + dragHandler.dragging = false; + dragHandler.dragAxisOffset = 0; + root.dragSourceIndex = -1; + root.dragTargetIndex = -1; + + if (wasDragging || isPlaceholder) + return; + + if (mouse.button === Qt.LeftButton) { + if (root.useExtWorkspace && (modelData?.id || modelData?.name)) { + ExtWorkspaceService.activateWorkspace(modelData.id || modelData.name, modelData.groupID || ""); + } else if (CompositorService.isNiri) { + if (modelData && modelData.idx !== undefined) { + NiriService.switchToWorkspace(modelData.idx); + } + } else if (CompositorService.isHyprland && modelData?.id) { Hyprland.dispatch(`workspace ${modelData.id}`); - } - } else if (CompositorService.isDwl && modelData?.tag !== undefined) { - console.log("DWL click - tag:", modelData.tag, "rightClick:", isRightClick); - if (isRightClick) { - console.log("Calling toggleTag"); - DwlService.toggleTag(root.screenName, modelData.tag); - } else { - console.log("Calling switchToTag"); + } else if (CompositorService.isDwl && modelData?.tag !== undefined) { DwlService.switchToTag(root.screenName, modelData.tag); + } else if ((CompositorService.isSway || CompositorService.isScroll) && modelData?.num) { + try { + I3.dispatch(`workspace number ${modelData.num}`); + } catch (_) {} + } + } else if (mouse.button === Qt.RightButton) { + if (CompositorService.isNiri) { + NiriService.toggleOverview(); + } else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) { + root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen; + } else if (CompositorService.isDwl && modelData?.tag !== undefined) { + DwlService.toggleTag(root.screenName, modelData.tag); } - } else if ((CompositorService.isSway || CompositorService.isScroll) && modelData?.num) { - try { - I3.dispatch(`workspace number ${modelData.num}`); - } catch (_) {} } } } @@ -1203,9 +1335,22 @@ Item { y: root.isVertical ? (parent.height - height) / 2 : (root.widgetHeight - height) / 2 radius: Theme.cornerRadius color: isActive ? activeColor : isUrgent ? urgentColor : isPlaceholder ? Theme.surfaceTextLight : isHovered ? Theme.withAlpha(unfocusedColor, 0.7) : isOccupied ? occupiedColor : unfocusedColor + opacity: dragHandler.dragging ? 0.8 : 1.0 - border.width: isUrgent ? 2 : 0 - border.color: isUrgent ? urgentColor : "transparent" + border.width: dragHandler.dragging ? 2 : (isUrgent ? 2 : (isDropTarget ? 2 : 0)) + border.color: dragHandler.dragging ? Theme.primary : (isUrgent ? urgentColor : (isDropTarget ? Theme.primary : "transparent")) + + transform: Translate { + x: root.isVertical ? 0 : (dragHandler.dragging ? dragHandler.dragAxisOffset : 0) + y: root.isVertical ? (dragHandler.dragging ? dragHandler.dragAxisOffset : 0) : 0 + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing + } + } Behavior on width { NumberAnimation { diff --git a/quickshell/Modules/Settings/WorkspacesTab.qml b/quickshell/Modules/Settings/WorkspacesTab.qml index 6f2322b5..4d34b519 100644 --- a/quickshell/Modules/Settings/WorkspacesTab.qml +++ b/quickshell/Modules/Settings/WorkspacesTab.qml @@ -155,6 +155,16 @@ Item { onToggled: checked => SettingsData.set("reverseScrolling", checked) } + SettingsToggleRow { + settingKey: "workspaceDragReorder" + tags: ["workspace", "drag", "reorder", "sort", "move"] + text: I18n.tr("Drag to Reorder") + description: I18n.tr("Drag workspace indicators to reorder them") + checked: SettingsData.workspaceDragReorder + visible: CompositorService.isNiri + onToggled: checked => SettingsData.set("workspaceDragReorder", checked) + } + SettingsToggleRow { settingKey: "dwlShowAllTags" tags: ["dwl", "tags", "workspace"] diff --git a/quickshell/Services/NiriService.qml b/quickshell/Services/NiriService.qml index 004c06de..93a5354c 100644 --- a/quickshell/Services/NiriService.qml +++ b/quickshell/Services/NiriService.qml @@ -1452,6 +1452,19 @@ Singleton { }); } + function moveWorkspaceToIndex(workspaceIdx, targetIndex) { + return send({ + "Action": { + "MoveWorkspaceToIndex": { + "index": targetIndex, + "reference": { + "Index": workspaceIdx + } + } + } + }); + } + IpcHandler { function screenshot(): string { if (!CompositorService.isNiri) {