Three things that were parked, and the reasons they were parked turned out to be the useful part of doing them. The Dock can sit on the left or the right as well as the bottom. Everything that assumed the bottom edge is now asked which edge it is on: the anchors, the axis that gets an implicit size, the sliver of input region that survives hiding, the direction the body slides away in, and which side a tooltip opens towards. The body was a Row and is a Grid, because one declaration then serves both orientations -- Row and Column would each need their own children, and the cross-axis anchors that centre items in a Row are the wrong axis in a Column. Bottom is unchanged in every particular, and the settings default to it, so a hot reload in the middle of this work left the running dock exactly where it was. One bug worth recording because static review would never have found it: a dock spans the edge it lives on, which means anchoring BOTH ends of that edge. The first side dock anchored top and left only, was free to collapse to its implicit height, and came out one pixel tall. It parsed, it loaded, and it rendered nothing. The contract measures the geometry rather than reading the source for that reason, and was verified by putting the single-ended anchor back. Per-screen is a list of names where empty means every screen, because a list is what goes stale when a display is unplugged and "all" should not be spelled as one. Turning off the last screen collapses to "all" rather than leaving no dock anywhere and no obvious way back. Pins can be dragged by a grip. The objection this file recorded for a long time was real -- dragging inside a Flickable inside a scrolling page fails in a way that reads as breakage -- and the answer is preventStealing on the grip, so the page cannot claim a gesture that started there. The arrow buttons stay: they are the keyboard-reachable path and a grip is not. The order is held locally during the drag and written once on release, rather than rewriting settings.json for every slot crossed. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
234 lines
8.8 KiB
QML
234 lines
8.8 KiB
QML
// The visible dock bar: the rounded translucent pill, its items, and the
|
|
// tooltip that floats above it.
|
|
//
|
|
// Kept separate from Dock.qml so it can be dropped straight into a
|
|
// FloatingWindow for testing — PanelWindow cannot map without layer shell.
|
|
|
|
import Quickshell
|
|
import Quickshell.Hyprland
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.widgets
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
// ── 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 }.
|
|
readonly property var items: {
|
|
// DesktopEntries is scanned asynchronously at startup, and byId() is a
|
|
// plain method call that creates no binding dependency. Reading the
|
|
// model here is what makes the dock fill in once the scan lands.
|
|
if (DesktopEntries.applications.values.length === 0)
|
|
return [];
|
|
|
|
const groups = {}; // appId -> [HyprlandToplevel]
|
|
const order = []; // appIds in first-seen order
|
|
|
|
const toplevels = Hyprland.toplevels.values;
|
|
for (let i = 0; i < toplevels.length; i++) {
|
|
const t = toplevels[i];
|
|
// `wayland` is null until Hyprland reports the address, and it is
|
|
// the only live source of an app id — there is no `class` property.
|
|
const appId = t.wayland ? t.wayland.appId : "";
|
|
if (!appId)
|
|
continue;
|
|
if (!groups[appId]) {
|
|
groups[appId] = [];
|
|
order.push(appId);
|
|
}
|
|
groups[appId].push(t);
|
|
}
|
|
|
|
const out = [];
|
|
const claimed = {};
|
|
|
|
const pinned = Settings.dockPinned;
|
|
for (let i = 0; i < pinned.length; i++) {
|
|
const entry = DesktopEntries.byId(pinned[i]);
|
|
// A pin that no longer resolves is dropped rather than drawn as a
|
|
// broken icon.
|
|
if (!entry)
|
|
continue;
|
|
|
|
let windows = [];
|
|
for (let j = 0; j < order.length; j++) {
|
|
if (!claimed[order[j]] && root.matches(order[j], entry, pinned[i])) {
|
|
claimed[order[j]] = true;
|
|
windows = windows.concat(groups[order[j]]);
|
|
}
|
|
}
|
|
out.push({
|
|
entry: entry,
|
|
windows: windows,
|
|
appId: pinned[i]
|
|
});
|
|
}
|
|
|
|
for (let i = 0; i < order.length; i++) {
|
|
const appId = order[i];
|
|
if (claimed[appId])
|
|
continue;
|
|
out.push({
|
|
entry: DesktopEntries.heuristicLookup(appId),
|
|
windows: groups[appId],
|
|
appId: appId
|
|
});
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// Wayland app ids and desktop entry ids agree often but not always
|
|
// (`org.gnome.Nautilus` vs `nautilus`, `Steam` vs `steam`), so compare the
|
|
// usual candidates case-insensitively and fall back to the last
|
|
// reverse-DNS segment.
|
|
function matches(appId: string, entry: var, pinId: string): bool {
|
|
const a = appId.toLowerCase();
|
|
if (a === pinId.toLowerCase())
|
|
return true;
|
|
if (entry.id && a === entry.id.toLowerCase())
|
|
return true;
|
|
if (entry.startupClass && a === entry.startupClass.toLowerCase())
|
|
return true;
|
|
|
|
const tail = a.split(".").pop();
|
|
return tail === pinId.toLowerCase().split(".").pop();
|
|
}
|
|
|
|
// ── Appearance ──────────────────────────────────────────────────────────
|
|
radius: Theme.dockRadius
|
|
color: Theme.alpha(Theme.bgPanel, Theme.dockAlpha)
|
|
border.width: 0 // QTBUG-137166: rounded translucent rects lose their corners
|
|
|
|
// The prism edge — see widgets/PrismEdge.qml.
|
|
PrismEdge {
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
inset: parent.radius
|
|
}
|
|
|
|
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
|
|
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
|
|
|
|
// The item the tooltip is currently describing, or null.
|
|
property Item hoveredItem: null
|
|
|
|
// Set by the Dock. A side dock runs the same strip down the screen instead
|
|
// of across it.
|
|
property bool vertical: false
|
|
|
|
// Which way a tooltip points on a side dock: away from the screen edge, so
|
|
// it never opens off-screen.
|
|
property bool leftSide: true
|
|
|
|
// 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.
|
|
readonly property int cellCount: 2 + (root.items ? root.items.length : 0)
|
|
|
|
// A Grid rather than a Row so one declaration serves both orientations.
|
|
// Row and Column would each need their own children, and the cross-axis
|
|
// anchors that centre items in a Row (verticalCenter) are the wrong axis in
|
|
// a Column -- Grid centres through its own alignment properties instead,
|
|
// which is the same result without the anchors positioners disallow.
|
|
Grid {
|
|
id: strip
|
|
anchors.centerIn: parent
|
|
spacing: Theme.dockGap
|
|
rows: root.vertical ? root.cellCount : 1
|
|
columns: root.vertical ? 1 : root.cellCount
|
|
horizontalItemAlignment: Grid.AlignHCenter
|
|
verticalItemAlignment: Grid.AlignVCenter
|
|
|
|
ShowAppsButton {
|
|
id: showApps
|
|
onEntered: root.hoveredItem = showApps
|
|
onExited: if (root.hoveredItem === showApps)
|
|
root.hoveredItem = null
|
|
}
|
|
|
|
// Separator between the launcher and the apps, as in GNOME's dash. It
|
|
// turns with the dock: a hairline across a column, down a row.
|
|
Rectangle {
|
|
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
|
|
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
|
|
border.width: 0
|
|
color: Theme.alpha(Theme.fg, 0.14)
|
|
}
|
|
|
|
Repeater {
|
|
model: root.items
|
|
|
|
DockItem {
|
|
id: dockItem
|
|
required property var modelData
|
|
app: modelData
|
|
onEntered: root.hoveredItem = dockItem
|
|
onExited: if (root.hoveredItem === dockItem)
|
|
root.hoveredItem = null
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tooltip ─────────────────────────────────────────────────────────────
|
|
// Styled like widgets/Popover.qml. Deliberately outside the dock's bounds
|
|
// (negative y): it renders fine there, and the dock's input mask does not
|
|
// cover it, so it can never swallow a click.
|
|
Rectangle {
|
|
id: tooltip
|
|
|
|
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
|
|
|
|
visible: opacity > 0
|
|
opacity: root.hoveredItem && tipLabel.text ? 1 : 0
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: Theme.durFast
|
|
}
|
|
}
|
|
|
|
// Centred on the hovered item along the dock's own axis, and placed just
|
|
// outside the edge it lives on. Reads the item's position directly
|
|
// (rather than mapToItem) so the binding re-evaluates when the strip
|
|
// reflows.
|
|
x: {
|
|
if (!root.hoveredItem)
|
|
return 0;
|
|
if (root.vertical)
|
|
return root.leftSide ? root.width + 8 : -width - 8;
|
|
const centre = strip.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
|
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
|
|
}
|
|
y: {
|
|
if (!root.hoveredItem)
|
|
return -height - 8;
|
|
if (!root.vertical)
|
|
return -height - 8;
|
|
const centre = strip.y + root.hoveredItem.y + root.hoveredItem.height / 2;
|
|
return Math.max(4, Math.min(root.height - height - 4, centre - height / 2));
|
|
}
|
|
|
|
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
|
|
height: tipLabel.implicitHeight + 8
|
|
|
|
radius: Theme.popoverRadius
|
|
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
|
border.width: 1
|
|
border.color: Theme.alpha(Theme.fg, 0.08)
|
|
|
|
Text {
|
|
id: tipLabel
|
|
anchors.centerIn: parent
|
|
text: tooltip.text
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
}
|
|
}
|