// The Dock's pinned applications, shown as the dock shows them. // // This replaces a sixteen-row list of names. The list was honest but it was not // the thing being edited: the Dock is a horizontal row of icons, and the order // of that row was being decided in a vertical column of text. Editing a // picture of the result is faster than editing a description of it, and it // removes the translation step where you count rows to work out which icon ends // up third. // // Icons resolve exactly the way the Dock resolves them, so what is drawn here // is what will be drawn there -- including the initial-letter fallback for an // application whose icon the theme cannot find. // // Reordering is a drag, with two things that make a drag safe inside a // scrolling page: // // * preventStealing on the grabbing MouseArea. Without it the page's own // Flickable claims the gesture and the icon never moves, which reads as // breakage rather than as a page that scrolls. // * The order is held here while the drag runs and written once on release. // Committing on every cell crossed would rewrite settings.json a dozen // times for one gesture. // // Every icon is also a tab stop: Left and Right move it, Delete unpins it. A // drag is not reachable from the keyboard, so the keyboard gets its own path // rather than a pair of arrow buttons bolted to each cell. import QtQuick import Quickshell import Quickshell.Widgets import qs.config import qs.services Column { id: root spacing: 0 // The cell is the icon plus the room its unpin button needs. Fixed, because // the drag arithmetic below counts cells rather than measuring them. readonly property int iconSize: 44 readonly property int cellSize: 52 readonly property int cellSpacing: 10 readonly property int stride: root.cellSize + root.cellSpacing // How many cells fit on one line. The drag turns a pointer offset into an // index, and on a wrapped strip moving down a line is a jump of this many. readonly property int perRow: Math.max(1, Math.floor((flow.width + root.cellSpacing) / root.stride)) 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; } // Desktop entries usually carry a freedesktop icon *name*, but some ship an // absolute path. iconPath() only understands names, so branch on it. The // `true` argument makes a missing icon return "" instead of a placeholder // that renders as a black square in some themes. function iconFor(id: string): string { const entry = root.entriesById[id]; const name = entry && entry.icon ? entry.icon : id; if (!name) return ""; if (name.startsWith("/")) return "file://" + name; return Quickshell.iconPath(name, true); } function commit(next: var): void { DesktopPreferences.set("dockPinned", next); } // ── Dragging ──────────────────────────────────────────────────────────── property int draggingIndex: -1 property var workingOrder: [] // The order the strip is drawing, as rows the Repeater can identify. A // plain array of ids is a NEW model every time the order changes, and // reordering is the one thing this strip does: handing that to a Repeater // resets it and destroys the delegate the pointer is holding, mid-press, on // the first cell crossed. The gesture dies there and draggingIndex stays // set, which latches the strip. Keyed on the pin id -- the only thing a pin // is -- a reorder becomes a row move instead, and the delegate under the // hand survives it. One-property objects because ScriptModel identifies a // row by a property of it, which a bare string has none of. readonly property var displayed: (root.draggingIndex >= 0 ? root.workingOrder : root.pinned) .map(id => ({ id: id })) 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); } // The drag let go of without a decision. Used when the cell being dragged // is destroyed under the pointer -- an unpin from somewhere else, a // settings file rewritten underneath -- where the working order is a // rearrangement of a list that no longer exists, and writing it back would // undo whatever really happened. function cancelDrag(): void { root.draggingIndex = -1; root.workingOrder = []; } // ── Keyboard ──────────────────────────────────────────────────────────── // // Which POSITION has the keyboard, remembered here rather than left to the // delegate that happens to hold focus. Unpinning really does destroy the // focused cell, and a keyed model still cannot keep focus on a row that is // gone; remembering the index means the cell that lands on it takes focus // back as it arrives, so Delete twice in a row deletes twice. property int keyboardIndex: -1 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.keyboardIndex = to; root.commit(next); } function remove(index: int): void { const next = root.pinned.slice(); next.splice(index, 1); root.keyboardIndex = Math.min(index, next.length - 1); root.commit(next); } function add(id: string): void { if (root.pinned.indexOf(id) >= 0) return; root.commit(root.pinned.concat([id])); } Flow { id: flow width: parent.width visible: root.displayed.length > 0 spacing: root.cellSpacing Repeater { // Keyed on the pin id: see `displayed`. This is the same reason // DockBody keys the dock's own icons. model: ScriptModel { values: root.displayed objectProp: "id" comparisonMode: ObjectComparison.Structure } delegate: Item { id: cell required property var modelData required property int index readonly property string appId: cell.modelData ? cell.modelData.id : "" readonly property bool dragging: root.draggingIndex === cell.index readonly property string appName: root.nameFor(cell.appId) readonly property string iconSource: root.iconFor(cell.appId) width: root.cellSize height: root.cellSize // The dragged cell rides above its neighbours so the icon being // moved is the one that looks moved. z: cell.dragging ? 2 : 0 activeFocusOnTab: true Accessible.role: Accessible.Button Accessible.name: cell.appName Accessible.description: "Pinned to the Dock, position " + (cell.index + 1) + " of " + root.displayed.length + ". Left and Right move it, Delete unpins it." Accessible.focusable: true Accessible.focused: cell.activeFocus Keys.onLeftPressed: root.move(cell.index, cell.index - 1) Keys.onRightPressed: root.move(cell.index, cell.index + 1) Keys.onDeletePressed: root.remove(cell.index) onActiveFocusChanged: if (cell.activeFocus) root.keyboardIndex = cell.index Component.onCompleted: if (root.keyboardIndex === cell.index) cell.forceActiveFocus() // A cell that goes away while it is the one being dragged takes // the release with it -- there is no MouseArea left to report // one -- and the strip would sit there with draggingIndex still // set, refusing every later press. Component.onDestruction: if (cell.dragging) root.cancelDrag() Connections { target: root function onKeyboardIndexChanged(): void { if (root.keyboardIndex === cell.index) cell.forceActiveFocus(); } } // Where the icon would land if the drag ended now. It sits in // the gap to the left of the cell rather than under it, because // the question a drop indicator answers is "between which two". Rectangle { visible: cell.dragging anchors.right: parent.left anchors.rightMargin: Math.round(root.cellSpacing / 2) - 2 anchors.verticalCenter: parent.verticalCenter width: 4 height: root.iconSize radius: 2 border.width: 0 color: Theme.accent } Rectangle { id: tile anchors.centerIn: parent width: root.iconSize height: root.iconSize radius: Theme.cardRadius color: cell.dragging ? Theme.alpha(Theme.accent, 0.18) : Theme.alpha(Theme.fg, grab.containsMouse || cell.activeFocus ? 0.09 : 0.05) border.width: cell.dragging || cell.activeFocus ? 2 : 1 border.color: cell.dragging ? Theme.accent : (cell.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.1)) IconImage { anchors.fill: parent anchors.margins: 5 visible: cell.iconSource !== "" source: cell.iconSource asynchronous: true mipmap: true } // Last resort for an app with no resolvable icon: an // initial, which is still recognizable, unlike the theme's // broken-icon placeholder. Text { anchors.centerIn: parent visible: cell.iconSource === "" text: cell.appName ? cell.appName.charAt(0).toUpperCase() : "?" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Math.round(root.iconSize * 0.5) } } // The whole tile is the grip. preventStealing is the reason a // drag works at all inside a scrolling page. MouseArea { id: grab anchors.fill: parent hoverEnabled: true preventStealing: true cursorShape: cell.dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor property real pressX: 0 property real pressY: 0 onPressed: mouse => { grab.pressX = mouse.x; grab.pressY = mouse.y; cell.forceActiveFocus(); root.beginDrag(cell.index); } onPositionChanged: mouse => { if (root.draggingIndex < 0) return; // How many whole cells the pointer has travelled from // where it started, across and down. Rounded, so the // swap happens as the icon passes the midpoint of its // neighbour; a line down is a jump of one full row. const columns = Math.round((mouse.x - grab.pressX) / root.stride); const rows = Math.round((mouse.y - grab.pressY) / root.stride); const slots = rows * root.perRow + columns; if (slots !== 0) root.dragTo(root.draggingIndex + slots); } onReleased: root.endDrag() onCanceled: root.endDrag() } // Unpin. Hidden until the icon is hovered or focused, because // eleven permanent × badges read as an error state rather than // as eleven applications you chose. Rectangle { id: unpin anchors.right: parent.right anchors.top: parent.top width: 18 height: 18 radius: 9 z: 3 visible: grab.containsMouse || unpinMouse.containsMouse || cell.activeFocus color: Theme.danger border.width: 0 Text { anchors.centerIn: parent text: "×" color: Theme.bgDark font.family: Theme.fontFamily font.pixelSize: 13 font.weight: Font.Bold } MouseArea { id: unpinMouse anchors.fill: parent hoverEnabled: true preventStealing: true cursorShape: Qt.PointingHandCursor onClicked: root.remove(cell.index) } } } } } Text { width: parent.width visible: root.pinned.length === 0 text: "Nothing is pinned — the Dock will only show running applications. Search below to add one." color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } }