Files
Panama/config/dot/quickshell/modules/settings/DockPinsStrip.qml
T
Gabriel Brown f8f5b25510 Make Shell a category, the bar legible, and the dock a real dock
Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling,
Workspaces — the home for everything Quickshell draws. The settings-
management cluster moves to System as Sync & Backup, Appearance's
Shell tab dissolves, and 24-hour time finally lives on Date & Time,
which always owned it.

The bar gets what it never had: a way to survive the wallpaper. A
second neutral text family (follow theme, or forced light or dark),
a one-layer shadow under every glyph, and a gradient scrim for
wallpapers nothing else survives — all off by default, pixel-identical
until asked. Widgets earn toggles (weather, media, clipboard, calendar
countdown), the vitals cluster stops leaving a dead pill behind, and
Control Center's sections learn to step aside.

The dock graduates from MVP: a context menu with window rows, pin,
unpin, quit and new-window; scroll an icon to cycle its windows; drag
to reorder on the dock itself; hover previews with one-shot captures;
and "Add App to Dock" in the launcher. Three real bugs died en route —
menus that slid away with the autohide, a readonly-property crash on
every menu open, and a drag that drifted half a slot per icon on side
docks. The pinned-apps editor in Settings becomes a drag strip.

166 contracts; the full suite is green except two live display and
switcher tests that cannot run behind a locked session — re-verified
on unlock.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 04:28:20 -04:00

342 lines
13 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}