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:
@@ -0,0 +1,341 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user