pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts import QtQml.Models import qs.Components import qs.Config Item { id: root property bool dragActive: false property real dragHeight: 0 property real dragStartX: 0 property real dragStartY: 0 property real dragX: 0 property real dragY: 0 property var draggedModelData: null property string draggedUid: "" property bool dropAnimating: false required property string name required property var object property var pendingCommitEntries: [] required property string setting property int uidCounter: 0 property var visualEntries: [] function beginVisualDrag(uid, modelData, item) { const pos = item.mapToItem(root, 0, 0); root.draggedUid = uid; root.draggedModelData = modelData; root.dragHeight = item.height; root.dragStartX = pos.x; root.dragStartY = pos.y; root.dragX = pos.x; root.dragY = pos.y; root.dragActive = true; root.dropAnimating = false; root.pendingCommitEntries = []; } function commitVisualOrder(entries) { const list = []; for (let i = 0; i < entries.length; i++) list.push(entries[i].entry); root.object[root.setting] = list; Config.save(); root.rebuildVisualEntries(); } function endVisualDrag() { const entries = root.visualEntries.slice(); const finalIndex = root.indexForUid(root.draggedUid); const finalItem = listView.itemAtIndex(finalIndex); root.dragActive = false; if (!finalItem) { root.pendingCommitEntries = entries; root.finishVisualDrag(); return; } const pos = finalItem.mapToItem(root, 0, 0); root.pendingCommitEntries = entries; root.dropAnimating = true; settleX.to = pos.x; settleY.to = pos.y; settleAnim.start(); } function ensureVisualEntries() { if (!root.dragActive && !root.dropAnimating) root.rebuildVisualEntries(); } function finishVisualDrag() { const entries = root.pendingCommitEntries.slice(); root.dragActive = false; root.dropAnimating = false; root.draggedUid = ""; root.draggedModelData = null; root.pendingCommitEntries = []; root.dragHeight = 0; root.commitVisualOrder(entries); } function iconForId(id) { switch (id) { case "workspaces": return "dashboard"; case "audio": return "volume_up"; case "media": return "play_arrow"; case "resources": return "monitoring"; case "updates": return "system_update"; case "dash": return "space_dashboard"; case "spacer": return "horizontal_rule"; case "activeWindow": return "web_asset"; case "tray": return "widgets"; case "upower": return "battery_full"; case "network": return "wifi"; case "clock": return "schedule"; case "notifBell": return "notifications"; default: return "drag_indicator"; } } function indexForUid(uid) { for (let i = 0; i < root.visualEntries.length; i++) { if (root.visualEntries[i].uid === uid) return i; } return -1; } function labelForId(id) { switch (id) { case "workspaces": return qsTr("Workspaces"); case "audio": return qsTr("Audio"); case "media": return qsTr("Media"); case "resources": return qsTr("Resources"); case "updates": return qsTr("Updates"); case "dash": return qsTr("Dash"); case "spacer": return qsTr("Spacer"); case "activeWindow": return qsTr("Active window"); case "tray": return qsTr("Tray"); case "upower": return qsTr("Power"); case "network": return qsTr("Network"); case "clock": return qsTr("Clock"); case "notifBell": return qsTr("Notification bell"); default: return id; } } function moveArrayItem(list, from, to) { const next = list.slice(); const [item] = next.splice(from, 1); next.splice(to, 0, item); return next; } function previewVisualMove(from, hovered, before) { let to = hovered + (before ? 0 : 1); if (to > from) to -= 1; to = Math.max(0, Math.min(visualModel.items.count - 1, to)); if (from === to) return; visualModel.items.move(from, to); root.visualEntries = root.moveArrayItem(root.visualEntries, from, to); } function rebuildVisualEntries() { const entries = root.object[root.setting] ?? []; const next = []; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; let existing = null; for (let j = 0; j < root.visualEntries.length; j++) { if (root.visualEntries[j].entry === entry) { existing = root.visualEntries[j]; break; } } if (existing) next.push(existing); else next.push({ uid: `entry-${root.uidCounter++}`, entry }); } root.visualEntries = next; } function updateEntry(index, value) { const list = [...root.object[root.setting]]; const entry = list[index]; entry.enabled = value; list[index] = entry; root.object[root.setting] = list; Config.save(); root.ensureVisualEntries(); } Layout.fillWidth: true implicitHeight: layout.implicitHeight Component.onCompleted: root.rebuildVisualEntries() ParallelAnimation { id: settleAnim onFinished: root.finishVisualDrag() Anim { id: settleX duration: Appearance.anim.durations.normal property: "dragX" target: root } Anim { id: settleY duration: Appearance.anim.durations.normal property: "dragY" target: root } } ColumnLayout { id: layout anchors.fill: parent spacing: Appearance.spacing.smaller CustomText { Layout.fillWidth: true font.pointSize: Appearance.font.size.larger text: root.name } DelegateModel { id: visualModel delegate: entryDelegate model: ScriptModel { objectProp: "uid" values: root.visualEntries } } ListView { id: listView Layout.fillWidth: true Layout.preferredHeight: contentHeight boundsBehavior: Flickable.StopAtBounds clip: false implicitHeight: contentHeight implicitWidth: width interactive: !(root.dragActive || root.dropAnimating) model: visualModel spacing: Appearance.spacing.small add: Transition { Anim { properties: "opacity,scale" to: 1 } } addDisplaced: Transition { Anim { duration: Appearance.anim.durations.normal property: "y" } } displaced: Transition { Anim { duration: Appearance.anim.durations.normal property: "y" } } move: Transition { Anim { duration: Appearance.anim.durations.normal property: "y" } } removeDisplaced: Transition { Anim { duration: Appearance.anim.durations.normal property: "y" } } } } Loader { active: root.dragActive || root.dropAnimating asynchronous: false sourceComponent: Item { Drag.active: root.dragActive Drag.hotSpot.x: width / 2 Drag.hotSpot.y: height / 2 height: proxyRect.implicitHeight implicitHeight: proxyRect.implicitHeight implicitWidth: listView.width visible: root.draggedModelData !== null width: listView.width x: root.dragX y: root.dragY z: 100 Drag.source: QtObject { property string uid: root.draggedUid property int visualIndex: root.indexForUid(root.draggedUid) } CustomRect { id: proxyRect color: DynamicColors.tPalette.m3surface implicitHeight: proxyRow.implicitHeight + Appearance.padding.small * 2 implicitWidth: parent.width opacity: 0.95 radius: Appearance.rounding.normal width: parent.width RowLayout { id: proxyRow anchors.fill: parent anchors.margins: Appearance.padding.small spacing: Appearance.spacing.normal CustomRect { color: Qt.alpha(DynamicColors.palette.m3onSurface, 0.12) implicitHeight: 32 implicitWidth: implicitHeight radius: Appearance.rounding.small MaterialIcon { anchors.centerIn: parent color: DynamicColors.palette.m3onSurfaceVariant text: "drag_indicator" } } MaterialIcon { color: DynamicColors.palette.m3onSurfaceVariant text: root.iconForId(root.draggedModelData?.entry?.id ?? "") } CustomText { Layout.fillWidth: true font.pointSize: Appearance.font.size.larger text: root.labelForId(root.draggedModelData?.entry?.id ?? "") } CustomSwitch { checked: root.draggedModelData?.entry?.enabled ?? true enabled: false } } } } } Component { id: entryDelegate DropArea { id: slot readonly property var entryData: modelData.entry required property var modelData readonly property string uid: modelData.uid function previewReorder(drag) { const source = drag.source; if (!source || !source.uid || source.uid === slot.uid) return; const from = source.visualIndex; const hovered = slot.DelegateModel.itemsIndex; if (from < 0 || hovered < 0) return; root.previewVisualMove(from, hovered, drag.y < height / 2); } height: entryRow.implicitHeight implicitHeight: entryRow.implicitHeight implicitWidth: listView.width width: ListView.view ? ListView.view.width : listView.width onEntered: drag => previewReorder(drag) onPositionChanged: drag => previewReorder(drag) CustomRect { id: entryRow anchors.fill: parent color: DynamicColors.tPalette.m3surface implicitHeight: entryLayout.implicitHeight + Appearance.padding.small * 2 implicitWidth: parent.width opacity: root.draggedUid === slot.uid ? 0 : 1 radius: Appearance.rounding.full Behavior on opacity { Anim { } } RowLayout { id: entryLayout anchors.fill: parent anchors.margins: Appearance.padding.small spacing: Appearance.spacing.normal CustomRect { id: handle color: Qt.alpha(DynamicColors.palette.m3onSurface, handleDrag.active ? 0.12 : handleHover.hovered ? 0.09 : 0.06) implicitHeight: 32 implicitWidth: implicitHeight radius: Appearance.rounding.full Behavior on color { CAnim { } } MaterialIcon { anchors.centerIn: parent color: DynamicColors.palette.m3onSurfaceVariant text: "drag_indicator" } HoverHandler { id: handleHover cursorShape: handleDrag.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor } DragHandler { id: handleDrag enabled: true grabPermissions: PointerHandler.CanTakeOverFromAnything target: null xAxis.enabled: false yAxis.enabled: true onActiveChanged: { if (active) { root.beginVisualDrag(slot.uid, slot.modelData, entryRow); } else if (root.draggedUid === slot.uid) { root.endVisualDrag(); } } onActiveTranslationChanged: { if (!active || root.draggedUid !== slot.uid) return; root.dragX = root.dragStartX; root.dragY = root.dragStartY + activeTranslation.y; } } } MaterialIcon { color: DynamicColors.palette.m3onSurfaceVariant text: root.iconForId(slot.entryData.id) } CustomText { Layout.fillWidth: true font.pointSize: Appearance.font.size.larger text: root.labelForId(slot.entryData.id) } CustomSwitch { Layout.rightMargin: Appearance.padding.small checked: slot.entryData.enabled ?? true onToggled: root.updateEntry(slot.DelegateModel.itemsIndex, checked) } } } } } }