// The Dock's pinned applications: reorder, remove, and add. // // The list was a sixteen-entry literal in Settings.qml, so changing what sits // in the Dock meant editing a QML file and reloading the shell. It is a plain // ordered list of desktop entry ids, stored in the shared settings file, which // means it is covered by Restore defaults like everything else. // // Move up / move down rather than drag-and-drop. Dragging inside a Flickable // that is itself inside a scrolling page is a genuinely hard interaction to get // right, and it fails in a way the user reads as the app being broken; two // buttons are unambiguous and keyboard-reachable. import QtQuick import Quickshell import qs.config import qs.services import qs.widgets Column { id: root spacing: 0 readonly property var pinned: { const stored = DesktopPreferences.get("dockPinned"); return Array.isArray(stored) ? stored : []; } // DesktopEntries populates asynchronously, so this must be read as a // binding rather than looked up inside one -- byId() called during // evaluation registers no dependency and answers from an empty list. readonly property var entriesById: { const index = {}; for (const entry of DesktopEntries.applications.values) index[entry.id] = entry; return index; } function nameFor(id: string): string { const entry = root.entriesById[id]; return entry ? entry.name : id; } function commit(next: var): void { DesktopPreferences.set("dockPinned", next); } // ── Dragging ──────────────────────────────────────────────────────────── // // By a grip rather than the whole row. The objection this file used to // record -- that dragging inside a Flickable inside a scrolling page is // hard to get right and fails in a way that reads as breakage -- is real, // and the answer is preventStealing on the grip: the Flickable cannot take // a gesture that started there, so a vertical drag reorders instead of // scrolling the page out from under it. The arrow buttons stay, because // they are the keyboard-reachable path and a grip is not. // // The order is held here while the drag runs and written once on release. // Committing on every slot crossed would rewrite settings.json a dozen // times for one gesture. property int draggingIndex: -1 property var workingOrder: [] readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned function beginDrag(index: int): void { root.workingOrder = root.pinned.slice(); root.draggingIndex = index; } function dragTo(target: int): void { if (root.draggingIndex < 0 || target === root.draggingIndex) return; if (target < 0 || target >= root.workingOrder.length) return; const next = root.workingOrder.slice(); const moved = next.splice(root.draggingIndex, 1)[0]; next.splice(target, 0, moved); root.workingOrder = next; root.draggingIndex = target; } function endDrag(): void { if (root.draggingIndex < 0) return; const next = root.workingOrder.slice(); root.draggingIndex = -1; root.workingOrder = []; root.commit(next); } function move(from: int, to: int): void { if (to < 0 || to >= root.pinned.length) return; const next = root.pinned.slice(); const moved = next.splice(from, 1)[0]; next.splice(to, 0, moved); root.commit(next); } function remove(index: int): void { const next = root.pinned.slice(); next.splice(index, 1); root.commit(next); } function add(id: string): void { if (root.pinned.indexOf(id) >= 0) return; root.commit(root.pinned.concat([id])); } Repeater { model: root.displayed SettingRow { id: pin required property var modelData required property int index label: root.nameFor(pin.modelData) // The desktop id is shown only when the name alone would not say // which entry this is. It is developer text, and repeating it under // fifteen recognisable application names is noise that makes the // list harder to scan, not easier. detail: root.pinned.filter(other => root.nameFor(other) === root.nameFor(pin.modelData)).length > 1 ? pin.modelData : "" divider: pin.index < root.pinned.length - 1 controlWidth: 132 // Lifted while dragging so the row being moved is the one that // looks moved. z: root.draggingIndex === pin.index ? 2 : 0 opacity: root.draggingIndex === pin.index ? 0.85 : 1 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 4 // The grip. preventStealing is the whole reason this works // inside a scrolling page: without it the Flickable claims the // vertical gesture and the row never moves. Item { width: 26 height: 26 anchors.verticalCenter: parent.verticalCenter Text { anchors.centerIn: parent text: "\u2261" color: root.draggingIndex === pin.index ? Theme.accent : Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: 15 } MouseArea { id: grip anchors.fill: parent preventStealing: true cursorShape: Qt.SizeVerCursor property real pressY: 0 onPressed: mouse => { grip.pressY = mouse.y; root.beginDrag(pin.index); } onPositionChanged: mouse => { if (root.draggingIndex < 0 || pin.height <= 0) return; // How many whole rows the pointer has travelled from // where it started. Rounded, so the swap happens as // the grip passes the midpoint of the next row. const travelled = (mouse.y - grip.pressY); const slots = Math.round(travelled / pin.height); if (slots !== 0) root.dragTo(root.draggingIndex + slots); } onReleased: root.endDrag() onCanceled: root.endDrag() } } SettingsButton { text: "↑" enabled: pin.index > 0 onClicked: root.move(pin.index, pin.index - 1) } SettingsButton { text: "↓" enabled: pin.index < root.pinned.length - 1 onClicked: root.move(pin.index, pin.index + 1) } SettingsButton { text: "Unpin" onClicked: root.remove(pin.index) } } } } SettingRow { visible: root.pinned.length === 0 label: "Nothing is pinned" detail: "The Dock will only show running applications" divider: false } }