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
342 lines
13 KiB
QML
342 lines
13 KiB
QML
// 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
|
||
}
|
||
}
|