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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user