Three things that were parked, and the reasons they were parked turned out to be the useful part of doing them. The Dock can sit on the left or the right as well as the bottom. Everything that assumed the bottom edge is now asked which edge it is on: the anchors, the axis that gets an implicit size, the sliver of input region that survives hiding, the direction the body slides away in, and which side a tooltip opens towards. The body was a Row and is a Grid, because one declaration then serves both orientations -- Row and Column would each need their own children, and the cross-axis anchors that centre items in a Row are the wrong axis in a Column. Bottom is unchanged in every particular, and the settings default to it, so a hot reload in the middle of this work left the running dock exactly where it was. One bug worth recording because static review would never have found it: a dock spans the edge it lives on, which means anchoring BOTH ends of that edge. The first side dock anchored top and left only, was free to collapse to its implicit height, and came out one pixel tall. It parsed, it loaded, and it rendered nothing. The contract measures the geometry rather than reading the source for that reason, and was verified by putting the single-ended anchor back. Per-screen is a list of names where empty means every screen, because a list is what goes stale when a display is unplugged and "all" should not be spelled as one. Turning off the last screen collapses to "all" rather than leaving no dock anywhere and no obvious way back. Pins can be dragged by a grip. The objection this file recorded for a long time was real -- dragging inside a Flickable inside a scrolling page fails in a way that reads as breakage -- and the answer is preventStealing on the grip, so the page cannot claim a gesture that started there. The arrow buttons stay: they are the keyboard-reachable path and a grip is not. The order is held locally during the drag and written once on release, rather than rewriting settings.json for every slot crossed. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
213 lines
7.8 KiB
QML
213 lines
7.8 KiB
QML
// 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
|
|
}
|
|
}
|