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
This commit is contained in:
Gabriel Brown
2026-08-24 04:28:20 -04:00
parent 4d7a194300
commit f8f5b25510
77 changed files with 2982 additions and 800 deletions
+212 -7
View File
@@ -16,7 +16,12 @@ Rectangle {
// ── Model ───────────────────────────────────────────────────────────────
// One pass over the live toplevel list produces the whole dock: pinned
// apps first in Settings order, then anything else that is running.
// Entries are plain JS objects: { entry, windows, appId }.
// Entries are plain JS objects: { entry, windows, appId, pinned }.
//
// `pinned` is carried rather than inferred later. Two things need it — the
// context menu, which cannot otherwise tell "Pin" from "Unpin", and the
// drag, which must refuse to reorder an icon that is only there because the
// app happens to be running.
readonly property var items: {
// DesktopEntries is scanned asynchronously at startup, and byId() is a
// plain method call that creates no binding dependency. Reading the
@@ -63,7 +68,8 @@ Rectangle {
out.push({
entry: entry,
windows: windows,
appId: pinned[i]
appId: pinned[i],
pinned: true
});
}
@@ -74,7 +80,8 @@ Rectangle {
out.push({
entry: DesktopEntries.heuristicLookup(appId),
windows: groups[appId],
appId: appId
appId: appId,
pinned: false
});
}
@@ -116,7 +123,11 @@ Rectangle {
// The item the tooltip is currently describing, or null.
property Item hoveredItem: null
signal contextMenuRequested(Item anchorItem, var entry)
// The whole app object travels, not just its desktop entry: the menu has to
// be able to list the app's windows and tell a pin from something that is
// merely running, and neither fact survives being narrowed to an entry.
signal contextMenuRequested(Item anchorItem, var app)
// Set by the Dock. A side dock runs the same strip down the screen instead
// of across it.
@@ -126,6 +137,171 @@ Rectangle {
// it never opens off-screen.
property bool leftSide: true
// ── Reordering ──────────────────────────────────────────────────────────
// Dragging an icon along the dock moves its pin. Nothing is written while
// the gesture runs: the dragged icon is translated under the pointer, the
// icons it passes are translated the other way by exactly one slot, and the
// spliced list is committed once on release. Committing per slot crossed
// would rewrite settings.json a dozen times for one gesture, and every
// rewrite re-evaluates `items` underneath the drag.
//
// Translation rather than assigned x/y because a Grid owns its children's
// positions; a transform is a purely visual offset the positioner ignores.
property int dragIndex: -1
property string dragId: ""
property real dragTravel: 0
readonly property bool dragging: root.dragIndex >= 0
// One cell plus the gap after it: the distance the strip moves things by.
// Reported by the item that started the drag rather than recomputed here --
// a DockItem is taller than it is wide, so a slot down a side dock is not
// the same distance as a slot across a bottom one. The initial value only
// has to be non-zero; the first drag replaces it with the measured pitch.
property real dragStep: Theme.dockIconSize + Theme.dockGap
// How many pins are on the dock. Resolved pins are always the leading run
// of `items`, so this doubles as the last index a drag may land on.
readonly property int pinnedCount: {
let count = 0;
for (let i = 0; i < root.items.length; i++) {
if (!root.items[i].pinned)
break;
count++;
}
return count;
}
readonly property int dropIndex: {
if (root.dragIndex < 0)
return -1;
const slots = Math.round(root.dragTravel / root.dragStep);
return Math.max(0, Math.min(root.pinnedCount - 1, root.dragIndex + slots));
}
// Where item `index` sits while a drag is in flight, relative to the slot
// the Grid put it in.
function dragShiftFor(index: int): real {
if (root.dragIndex < 0)
return 0;
if (index === root.dragIndex)
return root.dragTravel;
if (root.dropIndex > root.dragIndex && index > root.dragIndex && index <= root.dropIndex)
return -root.dragStep;
if (root.dropIndex < root.dragIndex && index >= root.dropIndex && index < root.dragIndex)
return root.dragStep;
return 0;
}
function beginDrag(index: int, pitch: real): void {
if (index < 0 || index >= root.pinnedCount)
return;
// A preview anchored to an icon that is about to move under the pointer
// is a surface pointing at nothing.
root.dismissPreview();
if (pitch > 0)
root.dragStep = pitch;
root.dragIndex = index;
root.dragId = root.items[index].appId;
root.dragTravel = 0;
}
function moveDrag(travel: real): void {
if (root.dragIndex < 0)
return;
root.dragTravel = travel;
}
// Resolved by pin id rather than by index. `items` drops a pin that no
// longer resolves, so an index into the dock is not an index into the
// stored list, and a window opening mid-drag can shift both.
function endDrag(): void {
const target = root.dropIndex;
const from = root.dragId;
const to = target >= 0 && target < root.items.length ? root.items[target].appId : "";
root.dragIndex = -1;
root.dragId = "";
root.dragTravel = 0;
if (!from || !to || from === to)
return;
const stored = Settings.dockPinned.slice();
const fromAt = stored.indexOf(from);
const toAt = stored.indexOf(to);
if (fromAt < 0 || toAt < 0)
return;
stored.splice(toAt, 0, stored.splice(fromAt, 1)[0]);
DesktopPreferences.set("dockPinned", stored);
}
function cancelDrag(): void {
root.dragIndex = -1;
root.dragId = "";
root.dragTravel = 0;
}
// ── Window previews ─────────────────────────────────────────────────────
// Hovering an icon long enough shows its windows. The dwell exists so that
// sweeping the pointer across the dock on the way somewhere else never
// opens anything, and the grace on the way out exists because the previews
// are their own surface: leaving the icon to reach them would otherwise
// close the thing being reached for.
property Item previewAnchor: null
property var previewApp: null
// Written by the Dock from the preview popup's own hover.
property bool previewHovered: false
onHoveredItemChanged: root.reconsiderPreview()
onPreviewHoveredChanged: root.reconsiderPreview()
function reconsiderPreview(): void {
const item = root.hoveredItem;
const eligible = item && item.app && item.app.windows && item.app.windows.length > 0;
if (eligible && item !== root.previewAnchor) {
previewGrace.stop();
previewDwell.restart();
return;
}
previewDwell.stop();
if (root.previewAnchor && !eligible && !root.previewHovered)
previewGrace.restart();
else if (eligible || root.previewHovered)
previewGrace.stop();
}
function dismissPreview(): void {
previewDwell.stop();
previewGrace.stop();
root.previewAnchor = null;
root.previewApp = null;
root.previewHovered = false;
}
Timer {
id: previewDwell
interval: 400
onTriggered: {
const item = root.hoveredItem;
if (!item || !item.app || !item.app.windows || item.app.windows.length === 0)
return;
root.previewAnchor = item;
root.previewApp = item.app;
}
}
Timer {
id: previewGrace
interval: 220
onTriggered: {
if (!root.previewHovered)
root.dismissPreview();
}
}
// Explicit rather than left to Grid's wrapping. This is always one line, so
// saying how many cells it holds is both simpler to read and immune to
// Grid's default column count quietly wrapping a long dock.
@@ -162,16 +338,43 @@ Rectangle {
}
Repeater {
model: root.items
// `items` is a fresh array of fresh objects on every change -- a
// window opening or closing anywhere rebuilds all of it. Handing
// that straight to Repeater resets the model and rebuilds every
// delegate, which throws away hover state, restarts the grow
// animation on icons nothing happened to, and would drop the
// delegate out from under a drag in progress. ScriptModel keyed on
// appId turns the same rebuild into "these rows changed", so an
// icon whose window count went up keeps its delegate.
model: ScriptModel {
values: root.items
objectProp: "appId"
comparisonMode: ObjectComparison.Structure
}
DockItem {
id: dockItem
required property var modelData
required property int index
app: modelData
vertical: root.vertical
// Only a pin can be reordered. An icon that is on the dock
// because its app happens to be running has no place in the
// stored list to move to.
draggable: modelData.pinned === true
dragShift: root.dragShiftFor(dockItem.index)
dragging: root.dragIndex === dockItem.index
onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.app)
onDragStarted: pitch => root.beginDrag(dockItem.index, pitch)
onDragMoved: travel => root.moveDrag(travel)
onDragEnded: root.endDrag()
onDragCancelled: root.cancelDrag()
}
}
}
@@ -186,7 +389,9 @@ Rectangle {
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
visible: opacity > 0
opacity: root.hoveredItem && tipLabel.text ? 1 : 0
// Yields to the window previews, which name the same app and more
// besides -- both at once is the same label twice.
opacity: root.hoveredItem && tipLabel.text && root.previewAnchor !== root.hoveredItem ? 1 : 0
Behavior on opacity {
NumberAnimation {