quickshell: pins as a 3-up tile grid with drag reorder

Pinned apps move out of the results list into their own tile grid, three
across, wrapping to further rows. Drag a tile onto another slot to
reorder; the id list is rewritten on release rather than mid-drag, so
nothing shifts under the cursor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-01 15:14:19 +01:00
parent c212b06d15
commit a15e4d5a32

View file

@ -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