// 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: [] 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); } // ── Keyboard ──────────────────────────────────────────────────────────── // // The Repeater's model is a plain array, so committing a move rebuilds // every delegate and the focused one is destroyed mid-keystroke. The // focused position is remembered here instead of in the delegate, and the // cell that lands on it takes focus back as it is created. 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 { model: root.displayed delegate: Item { id: cell required property var modelData required property int index readonly property bool dragging: root.draggingIndex === cell.index readonly property string appName: root.nameFor(cell.modelData) readonly property string iconSource: root.iconFor(cell.modelData) 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() 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 } }