diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 4df0d4d..a826ac9 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -1537,10 +1537,10 @@ in open = false; } - // ── Pinned apps: ids kept at the top of the list. Stored as - // one comma-joined string — .desktop ids never contain - // commas, and a plain string is what JsonAdapter is - // guaranteed to round-trip. + // ── Pinned apps: a 3-up tile grid above the results, in the + // order the ids are stored. Stored as one comma-joined + // string — .desktop ids never contain commas, and a plain + // string is what JsonAdapter is guaranteed to round-trip. FileView { id: pinFile path: Quickshell.env("HOME") + "/.local/state/quickshell-launcher-pins.json" @@ -1569,21 +1569,51 @@ in refilter(); } - // Drops ids whose .desktop file is gone (uninstalled app). - function pinnedApps() { + // Backing model for the tile grid. Drops ids whose .desktop + // file is gone (app uninstalled since it was pinned). + ListModel { id: pinModel } + + function syncPins() { const all = DesktopEntries.applications.values; - return pinIds().map(id => all.find(a => a.id === id)).filter(a => a !== undefined); + pinModel.clear(); + const ids = pinIds(); + for (let i = 0; i < ids.length; i++) { + const a = all.find(x => x.id === ids[i]); + if (a === undefined) continue; + pinModel.append({ + appId: a.id, + name: a.name, + icon: Quickshell.iconPath(a.icon, true) || "" + }); + } + } + + // Reorder from a drag. Rewrites the whole id list from the + // model, so a pin whose .desktop vanished is pruned here too. + function movePin(from, to) { + if (to === from || from < 0 || to < 0 + || from >= pinModel.count || to >= pinModel.count) return; + pinModel.move(from, to, 1); + let ids = []; + for (let i = 0; i < pinModel.count; i++) ids.push(pinModel.get(i).appId); + pinState.ids = ids.join(","); + pinFile.writeAdapter(); + } + + function launchPin(id) { + activate(DesktopEntries.applications.values.find(a => a.id === id)); } function refilter() { let q = searchInput.text.toLowerCase().trim(); calcResult = calc(searchInput.text); menuAppId = ""; + syncPins(); let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); let list; if (q === "") { if (activeCat === "") { - list = pinnedApps(); + list = []; } else { list = apps.filter(a => inCat(a, activeCat)); list.sort((a, b) => a.name.localeCompare(b.name)); @@ -1799,6 +1829,136 @@ in } } + // Pinned tiles, three across. Drag a tile onto + // another slot to reorder — the id list is rewritten + // on release, not mid-drag, so nothing jitters under + // the cursor while it moves. + Rectangle { + width: parent.width + height: pinGrid.height + 2 * Theme.cardPad + radius: Theme.radiusCard + color: Theme.cardBg + visible: pinModel.count > 0 && searchInput.text === "" + && launcherPanel.activeCat === "" + + Grid { + id: pinGrid + anchors.top: parent.top + anchors.topMargin: Theme.cardPad + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 2 * Theme.cardPad + columns: 3 + columnSpacing: 8 + rowSpacing: 8 + + readonly property real cellW: (width - (columns - 1) * columnSpacing) / columns + readonly property real cellH: 66 + + // Which slot a dragged tile's centre is over. + function slotAt(px, py) { + const c = Math.max(0, Math.min(columns - 1, + Math.floor(px / (cellW + columnSpacing)))); + const r = Math.max(0, Math.floor(py / (cellH + rowSpacing))); + return Math.max(0, Math.min(pinModel.count - 1, r * columns + c)); + } + + move: Transition { + NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo } + } + + Repeater { + model: pinModel + + // Cell stays where the Grid puts it; the + // tile inside is what the drag moves. + Item { + id: pinCell + required property string appId + required property string name + required property string icon + required property int index + width: pinGrid.cellW + height: pinGrid.cellH + z: pinMa.dragging ? 10 : 0 + + Rectangle { + id: pinTile + width: pinCell.width + height: pinCell.height + radius: Theme.radiusTiny + color: pinMa.containsMouse || pinMa.dragging ? Theme.base02 : Theme.base02t + Behavior on color { ColorAnimation { duration: Theme.animFade } } + // Snap home; a reorder has already + // moved the cell out from under it. + Behavior on x { enabled: !pinMa.dragging; NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutExpo } } + Behavior on y { enabled: !pinMa.dragging; NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutExpo } } + + Column { + anchors.centerIn: parent + spacing: 4 + + Image { + anchors.horizontalCenter: parent.horizontalCenter + visible: source != "" + width: 28 + height: 28 + sourceSize.width: 28 + sourceSize.height: 28 + source: pinCell.icon + } + SText { + width: pinCell.width - 12 + text: pinCell.name + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + MouseArea { + id: pinMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + drag.target: pinTile + drag.threshold: 6 + + // Latched for the whole press so + // the click that ends a drag can + // be told apart from a real click. + property bool dragging: false + drag.onActiveChanged: if (drag.active) dragging = true + + onPressed: dragging = false + onReleased: { + if (dragging) { + launcherPanel.movePin(pinCell.index, pinGrid.slotAt( + pinCell.x + pinTile.x + pinTile.width / 2, + pinCell.y + pinTile.y + pinTile.height / 2)); + dragging = false; + } + pinTile.x = 0; + pinTile.y = 0; + } + onClicked: (mouse) => { + if (dragging) return; + if (mouse.button === Qt.RightButton) { + const p = mapToItem(launcherPanel, mouse.x, mouse.y); + launcherPanel.menuX = p.x; + launcherPanel.menuY = p.y; + launcherPanel.menuAppId = pinCell.appId; + } else { + launcherPanel.launchPin(pinCell.appId); + } + } + } + } + } + } + } + } + // Category-browse breadcrumb: back to the grid. HoverRow { width: parent.width