// One dock icon: the app, its running-window dots, hover scale and click // behaviour. Reproduces Dash-to-Dock's item: 48px icon, up to four dots // underneath, grow-on-hover, click to focus / cycle / launch. import Quickshell import Quickshell.Hyprland import Quickshell.Widgets import QtQuick import qs.config Item { id: root // { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string } // Built by DockBody so this file stays presentational. required property var app readonly property DesktopEntry entry: app && app.entry ? app.entry : null readonly property var windows: app && app.windows ? app.windows : [] readonly property string label: entry ? entry.name : (app && app.appId ? app.appId : "") readonly property bool running: windows.length > 0 readonly property bool hovered: mouse.containsMouse // Emitted so DockBody can drive the single shared tooltip. signal entered signal exited // The icon may grow past the cell on hover; the cell itself stays a fixed // size so the row doesn't reflow. implicitWidth: Theme.dockIconSize implicitHeight: Theme.dockIconSize + dots.height + 4 // 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. readonly property string iconSource: { const name = entry && entry.icon ? entry.icon : (app && app.appId ? app.appId : ""); if (!name) return ""; if (name.startsWith("/")) return "file://" + name; return Quickshell.iconPath(name, true); } Item { id: iconSlot width: Theme.dockIconSize height: Theme.dockIconSize anchors.horizontalCenter: parent.horizontalCenter y: 0 // Grow upward out of the dock, the way GNOME's dash does, so the icon // never overlaps its own running dots. transformOrigin: Item.Bottom scale: root.hovered ? 1.15 : 1.0 Behavior on scale { NumberAnimation { duration: Theme.durFast easing.type: Easing.OutBack easing.overshoot: 1.6 } } IconImage { anchors.fill: parent visible: root.iconSource !== "" source: root.iconSource asynchronous: true mipmap: true } // Last resort for an app with no resolvable icon: an initial, which is // still recognisable, unlike the theme's broken-icon placeholder. Rectangle { anchors.fill: parent visible: root.iconSource === "" radius: Theme.cardRadius border.width: 0 color: Theme.alpha(Theme.fg, Theme.activeAlpha) Text { anchors.centerIn: parent text: root.label ? root.label.charAt(0).toUpperCase() : "?" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Math.round(Theme.dockIconSize * 0.5) } } } // Running indicators. Dash-to-Dock showed one dot per window up to four. Row { id: dots anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom spacing: 3 height: 4 Repeater { model: Math.min(root.windows.length, 4) Rectangle { width: 4 height: 4 radius: 2 border.width: 0 // QTBUG-137166: transparent rounded rects punch corners color: Theme.accent } } } MouseArea { id: mouse anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.MiddleButton onEntered: root.entered() onExited: root.exited() onClicked: mev => { // Middle click always starts a new instance, as in GNOME. if (mev.button === Qt.MiddleButton) { root.launch(); return; } if (root.running) root.focusNext(); else root.launch(); } } function launch(): void { if (root.entry) root.entry.execute(); } // Clicking a running app cycles through its windows, matching GNOME's dash. function focusNext(): void { const wins = root.windows; if (wins.length === 0) return; let next = wins[0]; for (let i = 0; i < wins.length; i++) { if (wins[i].activated) { next = wins[(i + 1) % wins.length]; break; } } if (!next) return; // Quickshell reports the address without the 0x prefix Hyprland's // window selector expects; normalise so either form works. if (next.address) { const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address; Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`); } else if (next.wayland) { // No address yet (the toplevel is still being reported) — the // wlr handle can still raise it. next.wayland.activate(); } } }